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 2ecf143d831 branch-4.1: [fix](balance) do not pick tablets whose data 
size is zero for BE balance #66499 (#66636)
2ecf143d831 is described below

commit 2ecf143d831292edfe8927e8ba6a966e96a6166c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Aug 11 18:41:11 2026 +0800

    branch-4.1: [fix](balance) do not pick tablets whose data size is zero for 
BE balance #66499 (#66636)
    
    Cherry-picked from #66499
    
    Co-authored-by: deardeng <[email protected]>
---
 .../org/apache/doris/clone/BeLoadRebalancer.java   |  39 +-
 .../java/org/apache/doris/clone/Rebalancer.java    |   7 +
 .../org/apache/doris/clone/TabletScheduler.java    |   2 +
 .../apache/doris/clone/TabletSchedulerStat.java    |   2 +
 .../clone/BeLoadRebalancePartitionSkewTest.java    | 526 +++++++++++++++++++++
 5 files changed, 572 insertions(+), 4 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/clone/BeLoadRebalancer.java 
b/fe/fe-core/src/main/java/org/apache/doris/clone/BeLoadRebalancer.java
index e1460c269c1..d54605ded1b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/clone/BeLoadRebalancer.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/clone/BeLoadRebalancer.java
@@ -123,6 +123,10 @@ public class BeLoadRebalancer extends Rebalancer {
                 .collect(Collectors.toList());
 
         boolean hasCandidateTablet = false;
+        // Only for logging. This counts scanned tablets, not balance 
attempts, so it is summarized
+        // once per round here instead of going into TabletSchedulerStat: when 
every replica size is
+        // still unreported, every tablet of every high load backend hits it 
on every round.
+        int zeroSizeTabletNum = 0;
 
         // choose tablets from high load backends.
         // BackendLoadStatistic is sorted by load score in ascend order,
@@ -225,6 +229,18 @@ public class BeLoadRebalancer extends Rebalancer {
                         continue;
                     }
 
+                    // A tablet with no data relocates nothing, so moving it 
can not improve the disk usage
+                    // difference that triggered this balance. It only shifts 
the replica count term of the
+                    // load score, which destroys the round-robin distribution 
a newly created table got
+                    // from createTablets(). Zero also means the size has not 
been reported yet: replica
+                    // data size is not persisted in the image (see 
LocalTablet), so it remains zero after
+                    // an FE restart until the next tablet stat update, and 
balancing on an unknown size is
+                    // guesswork. This is the same rule as 
DiskRebalancer.completeSchedCtx().
+                    if (replicaDataSize <= 0) {
+                        zeroSizeTabletNum++;
+                        continue;
+                    }
+
                     hasCandidateTablet = true;
 
                     // for urgent disk, pick tablets order by size,
@@ -273,11 +289,16 @@ public class BeLoadRebalancer extends Rebalancer {
         } // end for high backends
 
         if (!alternativeTablets.isEmpty()) {
-            LOG.info("select alternative tablets, medium: {}, is urgent: {}, 
num: {}, detail: {}",
-                    medium, isUrgent, alternativeTablets.size(), 
alternativeTabletInfos);
+            LOG.info("select alternative tablets, medium: {}, is urgent: {}, 
num: {},"
+                            + " skip {} tablets whose size is zero, detail: 
{}",
+                    medium, isUrgent, alternativeTablets.size(), 
zeroSizeTabletNum, alternativeTabletInfos);
         } else if (isUrgent && !hasCandidateTablet) {
-            LOG.info("urgent balance cann't found candidate tablets. medium: 
{}, tag: {}",
-                    medium, clusterStat.getTag());
+            LOG.info("urgent balance cann't found candidate tablets. medium: 
{}, tag: {},"
+                            + " skip {} tablets whose size is zero",
+                    medium, clusterStat.getTag(), zeroSizeTabletNum);
+        } else if (zeroSizeTabletNum > 0) {
+            LOG.info("no alternative tablet, {} tablets are skipped because 
their size is zero."
+                            + " medium: {}, tag: {}", zeroSizeTabletNum, 
medium, clusterStat.getTag());
         }
         return alternativeTablets;
     }
@@ -334,6 +355,16 @@ public class BeLoadRebalancer extends Rebalancer {
                     "no replica on high load backend" + isUrgentInfo);
         }
 
+        // Recheck because selection and scheduling can be far apart. Zero 
also means the size has not been
+        // reported yet: replica data size is not persisted in the image (see 
LocalTablet), so it remains zero
+        // after an FE restart until the next tablet stat update. Balancing on 
an unknown size is guesswork.
+        // This is the same rule as DiskRebalancer.completeSchedCtx().
+        if (tabletCtx.getTabletSize() <= 0) {
+            schedulerStat.counterBalanceRejectByZeroDataSize.incrementAndGet();
+            throw new SchedException(Status.UNRECOVERABLE, 
SubCode.DIAGNOSE_IGNORE,
+                    "size of src replica is zero");
+        }
+
         // select a replica as source
         boolean setSource = false;
         for (Replica replica : replicas) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/clone/Rebalancer.java 
b/fe/fe-core/src/main/java/org/apache/doris/clone/Rebalancer.java
index af8bc6d67fc..3938029fa81 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/clone/Rebalancer.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/clone/Rebalancer.java
@@ -64,6 +64,9 @@ public abstract class Rebalancer {
     protected Map<Long, PathSlot> backendsWorkingSlots;
     protected TabletInvertedIndex invertedIndex;
     protected SystemInfoService infoService;
+    // Owned by TabletScheduler, which injects itself via setSchedulerStat() 
after construction.
+    // Defaults to a standalone instance so that unit tests can build a 
Rebalancer on its own.
+    protected TabletSchedulerStat schedulerStat = new TabletSchedulerStat();
     // be id -> end time of prio
     protected Map<Long, Long> prioBackends = Maps.newConcurrentMap();
 
@@ -163,6 +166,10 @@ public abstract class Rebalancer {
         this.statisticMap = statisticMap;
     }
 
+    public void setSchedulerStat(TabletSchedulerStat schedulerStat) {
+        this.schedulerStat = schedulerStat;
+    }
+
     public void updateAlterTableIds(Set<Long> alterTableIds) {
         this.alterTableIds = alterTableIds;
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/clone/TabletScheduler.java 
b/fe/fe-core/src/main/java/org/apache/doris/clone/TabletScheduler.java
index a15830efe01..21901efead1 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/clone/TabletScheduler.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/clone/TabletScheduler.java
@@ -165,6 +165,8 @@ public class TabletScheduler extends MasterDaemon {
         }
         // if rebalancer can not get new task, then use diskRebalancer to get 
task
         this.diskRebalancer = new DiskRebalancer(infoService, invertedIndex, 
backendsWorkingSlots);
+        this.rebalancer.setSchedulerStat(stat);
+        this.diskRebalancer.setSchedulerStat(stat);
     }
 
     // for fe ut
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/clone/TabletSchedulerStat.java 
b/fe/fe-core/src/main/java/org/apache/doris/clone/TabletSchedulerStat.java
index 7510b06fe22..4a36babf9de 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/clone/TabletSchedulerStat.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/clone/TabletSchedulerStat.java
@@ -111,6 +111,8 @@ public class TabletSchedulerStat {
     public AtomicLong counterReplicaMissingForTagErr = new AtomicLong(0L);
     @StatField("num of balance scheduled")
     public AtomicLong counterBalanceSchedule = new AtomicLong(0L);
+    @StatField("num of BE balance rejected for zero data size")
+    public AtomicLong counterBalanceRejectByZeroDataSize = new AtomicLong(0L);
     @StatField("num of colocate replica mismatch")
     public AtomicLong counterReplicaColocateMismatch = new AtomicLong(0L);
     @StatField("num of colocate replica redundant")
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/clone/BeLoadRebalancePartitionSkewTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/clone/BeLoadRebalancePartitionSkewTest.java
new file mode 100644
index 00000000000..4de236c470c
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/clone/BeLoadRebalancePartitionSkewTest.java
@@ -0,0 +1,526 @@
+// 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.clone;
+
+import org.apache.doris.alter.Alter;
+import org.apache.doris.catalog.ColocateTableIndex;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.DataProperty;
+import org.apache.doris.catalog.Database;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.HashDistributionInfo;
+import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.LocalReplica;
+import org.apache.doris.catalog.LocalTabletInvertedIndex;
+import org.apache.doris.catalog.MaterializedIndex;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.Partition;
+import org.apache.doris.catalog.RangePartitionInfo;
+import org.apache.doris.catalog.Replica;
+import org.apache.doris.catalog.ReplicaAllocation;
+import org.apache.doris.catalog.Tablet;
+import org.apache.doris.catalog.TabletInvertedIndex;
+import org.apache.doris.catalog.TabletMeta;
+import org.apache.doris.clone.TabletScheduler.PathSlot;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.resource.Tag;
+import org.apache.doris.system.Backend;
+import org.apache.doris.system.SystemInfoService;
+import org.apache.doris.thrift.TStorageMedium;
+import org.apache.doris.thrift.TStorageType;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Reproduce DORIS-27244: a freshly created table is distributed round-robin, 
but the default
+ * BeLoadRebalancer immediately migrates its (still empty) tablets away and 
destroys that
+ * distribution.
+ *
+ * <p>Root cause under test: the BE load score mixes two terms
+ * (see {@code BackendLoadStatistic#calcScore})
+ *
+ * <pre>
+ *   score = capacityProportion * capCoeff + replicaNumProportion * (1 - 
capCoeff)
+ * </pre>
+ *
+ * A brand new tablet has dataSize == 0, so moving it leaves the capacity term 
untouched while the
+ * replica-count term improves. {@code LoadStatisticForTag#isMoreBalanced} 
therefore accepts the
+ * move even though zero bytes are relocated. The disk-usage difference that 
triggered the balance
+ * (partly non-Doris data, which no tablet move can ever fix) gets 
"compensated" by shifting replica
+ * counts, and the per-partition replica distribution is the thing that pays 
for it.
+ */
+public class BeLoadRebalancePartitionSkewTest {
+    private static final Logger LOG = 
LogManager.getLogger(BeLoadRebalancePartitionSkewTest.class);
+
+    private static final long MB = 1024L * 1024L;
+    private static final long GB = 1024L * MB;
+
+    // TPC-DS store_sales in the issue: 261 buckets, replication_num = 1, 16 
BEs
+    private static final int BE_NUM = 16;
+    private static final int TABLET_NUM = 261;
+    // other tables already living in the cluster: the issue's screenshot 
shows 960 replicas in
+    // total, i.e. ~60 per BE. That ratio matters: it decides how much one 
single replica move
+    // shifts the replica-count term of the load score.
+    private static final int BACKGROUND_TABLET_NUM = 45 * BE_NUM;
+    private static final long BE_TOTAL_CAPACITY = 1000 * GB;
+
+    private static final long FIRST_BE_ID = 10001L;
+    private static final long DB_ID = 1L;
+    private static final long TABLE_ID = 2L;
+    private static final long PARTITION_ID = 31L;
+    private static final long BACKGROUND_TABLE_ID = 3L;
+    private static final long BACKGROUND_PARTITION_ID = 41L;
+
+    private Env env;
+    private MockedStatic<Env> mockedEnvStatic;
+    private TabletSchedulerStat schedulerStat;
+
+    private Database db;
+    private final SystemInfoService systemInfoService = new 
SystemInfoService();
+    private final TabletInvertedIndex invertedIndex = new 
LocalTabletInvertedIndex();
+
+    private long nextId = 100000L;
+    private String origRebalancerType;
+
+    @Before
+    public void setUp() throws Exception {
+        FeConstants.runningUnitTest = true;
+        origRebalancerType = Config.tablet_rebalancer_type;
+        Config.tablet_rebalancer_type = "BeLoad";
+
+        db = new Database(DB_ID, "test_db");
+
+        env = Mockito.mock(Env.class);
+        InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
+        Alter alter = Mockito.mock(Alter.class);
+        ColocateTableIndex colocateTableIndex = 
Mockito.mock(ColocateTableIndex.class);
+        schedulerStat = new TabletSchedulerStat();
+
+        Mockito.when(env.getInternalCatalog()).thenReturn(catalog);
+        
Mockito.when(catalog.getDbIds()).thenReturn(Lists.newArrayList(db.getId()));
+        Mockito.when(catalog.getDbNullable(Mockito.anyLong())).thenReturn(db);
+        Mockito.when(catalog.getDbOrException(Mockito.anyLong(), 
Mockito.any())).thenReturn(db);
+        Mockito.when(env.getNextId()).thenAnswer(inv -> nextId++);
+        Mockito.when(env.getAlterInstance()).thenReturn(alter);
+        
Mockito.when(alter.getUnfinishedAlterTableIds()).thenReturn(Collections.emptySet());
+        
Mockito.when(env.getColocateTableIndex()).thenReturn(colocateTableIndex);
+        
Mockito.when(colocateTableIndex.isColocateTable(Mockito.anyLong())).thenReturn(false);
+
+        mockedEnvStatic = Mockito.mockStatic(Env.class);
+        mockedEnvStatic.when(Env::getCurrentEnv).thenReturn(env);
+        
mockedEnvStatic.when(Env::getCurrentEnvJournalVersion).thenReturn(FeConstants.meta_version);
+        
mockedEnvStatic.when(Env::getCurrentSystemInfo).thenReturn(systemInfoService);
+        
mockedEnvStatic.when(Env::getCurrentInvertedIndex).thenReturn(invertedIndex);
+        
mockedEnvStatic.when(Env::getCurrentColocateIndex).thenReturn(colocateTableIndex);
+        
mockedEnvStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+    }
+
+    @After
+    public void tearDown() {
+        if (mockedEnvStatic != null) {
+            mockedEnvStatic.close();
+        }
+        Config.tablet_rebalancer_type = origRebalancerType;
+    }
+
+    /**
+     * The regression test for DORIS-27244.
+     *
+     * <p>16 BEs with identical disk capacity but slightly different disk 
usage (10.0% ~ 16.3%,
+     * which yields CapCoeff = 0.5256, exactly what the issue's screenshot 
shows). That difference
+     * is disk level, not replica level: the BEs hold the same amount of Doris 
data, the rest is
+     * non-Doris usage that no tablet migration can ever remove.
+     *
+     * <p>On top of that cluster a table is created: 261 tablets, replication 
1, distributed
+     * round-robin, so every BE holds 16 or 17 replicas and all of them are 
still empty.
+     *
+     * <p>Expected: the empty tablets do not move, so the new table keeps its 
even distribution.
+     */
+    @Test
+    public void testEmptyTabletBalanceShouldNotBreakRoundRobin() {
+        List<Long> beIds = createBackendsWithSkewedDiskUsage();
+        // an already loaded table, evenly distributed: 45 replicas of 1GB on 
every BE
+        MaterializedIndex background = 
createRoundRobinTable(BACKGROUND_TABLE_ID, "background",
+                BACKGROUND_PARTITION_ID, 60000, BACKGROUND_TABLET_NUM, beIds, 
1 * GB);
+        // the table that was just created, all its tablets are still empty
+        MaterializedIndex storeSales = createRoundRobinTable(TABLE_ID, 
"store_sales",
+                PARTITION_ID, 50000, TABLET_NUM, beIds, 0L);
+
+        LOG.info("store_sales before balance: {}", 
sortedCounts(countReplicaPerBe(storeSales)));
+        LOG.info("background before balance: {}", 
sortedCounts(countReplicaPerBe(background)));
+        Assert.assertEquals(1, skewOf(countReplicaPerBe(storeSales)));
+        Assert.assertEquals(0, skewOf(countReplicaPerBe(background)));
+
+        int moves = runBalanceUntilStable(Lists.newArrayList(storeSales, 
background), 100);
+
+        Map<Long, Integer> storeSalesAfter = countReplicaPerBe(storeSales);
+        Map<Long, Integer> backgroundAfter = countReplicaPerBe(background);
+        LOG.info("store_sales after balance: {}, moves: {}", 
sortedCounts(storeSalesAfter), moves);
+        LOG.info("background after balance: {}", 
sortedCounts(backgroundAfter));
+
+        Assert.assertTrue("the newly created table must keep its round-robin 
distribution,"
+                        + " actual: " + sortedCounts(storeSalesAfter),
+                skewOf(storeSalesAfter) <= 1);
+        // Loaded tablets can legitimately move for capacity balancing. Any 
resulting per-index
+        // skew belongs to a follow-up change and is intentionally only 
observed here.
+        LOG.info("background table skew after legitimate capacity balance: 
{}", skewOf(backgroundAfter));
+    }
+
+    /**
+     * Replica data size is not persisted in the FE image. After an FE restart 
a loaded tablet's
+     * size remains zero until the next tablet stat report, and balancing with 
that unknown size
+     * must be blocked.
+     */
+    @Test
+    public void testLoadedTableIsNotBalancedBeforeSizeIsReportedAfterRestart() 
{
+        List<Long> beIds = createBackendsWithSkewedDiskUsage();
+        MaterializedIndex loadedTable = createRoundRobinTable(TABLE_ID, 
"loaded_table",
+                PARTITION_ID, 50000, TABLET_NUM, beIds, 1 * GB);
+        setReplicaSizes(loadedTable, 0L);
+
+        Map<Long, Integer> before = countReplicaPerBe(loadedTable);
+        int moves = runBalanceUntilStable(Lists.newArrayList(loadedTable), 
100);
+        Map<Long, Integer> after = countReplicaPerBe(loadedTable);
+
+        Assert.assertEquals("no tablet with an unreported size should move", 
0, moves);
+        Assert.assertEquals("the loaded table must retain its distribution 
during the restart window",
+                before, after);
+        // Selection already filters these out, so nothing ever reaches the 
scheduler and the stat
+        // counter stays at zero. How many tablets were skipped is reported in 
the round summary log
+        // instead, because it counts scanned tablets rather than balance 
attempts.
+        Assert.assertEquals("selection should filter zero-size tablets before 
scheduling",
+                0L, schedulerStat.counterBalanceRejectByZeroDataSize.get());
+    }
+
+    /** The zero-size guard is unconditional and also applies to urgent BE 
balance. */
+    @Test
+    public void 
testUrgentBalanceRejectsTabletWhoseSizeBecomesZeroBeforeScheduling() {
+        List<Long> beIds = createBackendsWithUrgentDiskUsage();
+        long highBeId = beIds.get(beIds.size() - 1);
+        OlapTable table = createTable(TABLE_ID, "urgent_table", PARTITION_ID);
+        MaterializedIndex index = 
table.getPartition(PARTITION_ID).getBaseIndex();
+        for (int i = 0; i < 8; i++) {
+            RebalancerTestUtil.createTablet(invertedIndex, db, table, "p0", 
TStorageMedium.HDD,
+                    50000 + i, Lists.newArrayList(highBeId), 
Lists.newArrayList(1 * GB));
+        }
+
+        Map<Long, PathSlot> slots = createWorkingSlots();
+        BeLoadRebalancer rebalancer = new BeLoadRebalancer(systemInfoService, 
invertedIndex, slots);
+        rebalancer.setSchedulerStat(schedulerStat);
+        LoadStatisticForTag loadStatistic = newLoadStatistic(rebalancer);
+        List<BackendLoadStatistic> lowBEs = Lists.newArrayList();
+        List<BackendLoadStatistic> highBEs = Lists.newArrayList();
+        Assert.assertTrue("the test must exercise urgent balance",
+                loadStatistic.getLowHighBEsWithIsUrgent(lowBEs, highBEs, 
TStorageMedium.HDD));
+        rebalancer.updateLoadStatistic(Maps.newHashMap(
+                Collections.singletonMap(Tag.DEFAULT_BACKEND_TAG, 
loadStatistic)));
+
+        List<TabletSchedCtx> candidates = 
rebalancer.selectAlternativeTablets();
+        Assert.assertFalse("a reported, non-empty tablet should be selected", 
candidates.isEmpty());
+        TabletSchedCtx tabletCtx = candidates.get(0);
+        Tablet tablet = findTablet(Lists.newArrayList(index), 
tabletCtx.getTabletId());
+        Assert.assertNotNull(tablet);
+        setReplicaSizes(index, 0L);
+        tabletCtx.setTablet(tablet);
+        tabletCtx.updateTabletSize();
+        tabletCtx.setStorageMedium(TStorageMedium.HDD);
+
+        Replica replica = tablet.getReplicaByBackendId(highBeId);
+        int availableSlotsBefore = 
slots.get(highBeId).getAvailableBalanceNum(replica.getPathHash());
+        try {
+            rebalancer.completeSchedCtx(tabletCtx);
+            Assert.fail("urgent balance must reject a tablet whose size is no 
longer reported");
+        } catch (SchedException e) {
+            Assert.assertTrue(e.getMessage().contains("size of src replica is 
zero"));
+        }
+        Assert.assertEquals("the zero-size check must run before taking the 
source slot",
+                availableSlotsBefore, 
slots.get(highBeId).getAvailableBalanceNum(replica.getPathHash()));
+        Assert.assertEquals("the scheduling-time rejection should increment 
the counter",
+                1L, schedulerStat.counterBalanceRejectByZeroDataSize.get());
+    }
+
+    /**
+     * Documents the mechanism itself, independent of any fix: with a 
zero-sized tablet the capacity
+     * term of the load score does not move at all, yet the move is still 
accepted as "more
+     * balanced" purely because of the replica-count term.
+     */
+    @Test
+    public void testZeroSizeMoveIsAcceptedAlthoughCapacityIsUnchanged() {
+        List<Long> beIds = createBackendsWithSkewedDiskUsage();
+        createRoundRobinTable(TABLE_ID, "store_sales", PARTITION_ID, 50000, 
TABLET_NUM, beIds, 0L);
+
+        LoadStatisticForTag stat = newLoadStatistic(null);
+        List<BackendLoadStatistic> lowBEs = Lists.newArrayList();
+        List<BackendLoadStatistic> highBEs = Lists.newArrayList();
+        stat.getLowHighBEsWithIsUrgent(lowBEs, highBEs, TStorageMedium.HDD);
+
+        Assert.assertFalse("the disk usage spread should classify some BE as 
HIGH", highBEs.isEmpty());
+        Assert.assertFalse("the disk usage spread should classify some BE as 
LOW", lowBEs.isEmpty());
+
+        BackendLoadStatistic high = highBEs.get(highBEs.size() - 1);
+        BackendLoadStatistic low = lowBEs.get(0);
+
+        long highUsedBefore = high.getTotalUsedCapacityB(TStorageMedium.HDD);
+        long lowUsedBefore = low.getTotalUsedCapacityB(TStorageMedium.HDD);
+
+        Assert.assertTrue("a zero-sized tablet is accepted as a balance move 
even though it"
+                        + " relocates no data at all",
+                stat.isMoreBalanced(high.getBeId(), low.getBeId(), 50000L, 0L, 
TStorageMedium.HDD));
+
+        Assert.assertEquals("the capacity term is untouched by a zero-sized 
move",
+                highUsedBefore, 
high.getTotalUsedCapacityB(TStorageMedium.HDD));
+        Assert.assertEquals("the capacity term is untouched by a zero-sized 
move",
+                lowUsedBefore, low.getTotalUsedCapacityB(TStorageMedium.HDD));
+    }
+
+    /**
+     * The counter test: the fix must not turn BeLoadRebalancer into a no-op. 
A genuinely skewed
+     * index (all replicas piled on one BE) still has to be spread out.
+     */
+    @Test
+    public void testGenuinelySkewedIndexIsStillBalanced() {
+        List<Long> beIds = Lists.newArrayList();
+        for (int i = 0; i < 4; i++) {
+            long beId = FIRST_BE_ID + i;
+            // identical disks, so only the replica count term drives the 
balance
+            systemInfoService.addBackend(
+                    RebalancerTestUtil.createBackend(beId, BE_TOTAL_CAPACITY, 
100 * GB));
+            beIds.add(beId);
+        }
+
+        OlapTable table = createTable(TABLE_ID, "store_sales", PARTITION_ID);
+        MaterializedIndex index = 
table.getPartition(PARTITION_ID).getBaseIndex();
+        // all 8 tablets sit on the first BE
+        for (int i = 0; i < 8; i++) {
+            RebalancerTestUtil.createTablet(invertedIndex, db, table, "p0", 
TStorageMedium.HDD,
+                    50000 + i, Lists.newArrayList(beIds.get(0)), 
Lists.newArrayList(1 * GB));
+        }
+
+        Map<Long, Integer> before = countReplicaPerBe(index);
+        LOG.info("skewed distribution before balance: {}", 
sortedCounts(before));
+        Assert.assertEquals(8, skewOf(before));
+
+        int moves = runBalanceUntilStable(Lists.newArrayList(index), 100);
+        Map<Long, Integer> after = countReplicaPerBe(index);
+        LOG.info("skewed distribution after balance: {}, moves: {}", 
sortedCounts(after), moves);
+
+        Assert.assertTrue("balancer should have moved replicas away from the 
overloaded BE",
+                moves > 0);
+        Assert.assertTrue("a genuinely skewed index must still be balanced, 
actual: "
+                + sortedCounts(after), skewOf(after) <= 1);
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // helpers
+    // 
------------------------------------------------------------------------------------------
+
+    /**
+     * 16 BEs, same total capacity, disk usage from 10.0% up to 16.3%. Part of 
that difference is
+     * non-Doris data in reality; no tablet migration can ever remove it.
+     */
+    private List<Long> createBackendsWithSkewedDiskUsage() {
+        List<Long> beIds = Lists.newArrayList();
+        for (int i = 0; i < BE_NUM; i++) {
+            long beId = FIRST_BE_ID + i;
+            long usedCapacity = 100 * GB + i * (4 * GB + 200 * MB);
+            systemInfoService.addBackend(
+                    RebalancerTestUtil.createBackend(beId, BE_TOTAL_CAPACITY, 
usedCapacity));
+            beIds.add(beId);
+        }
+        return beIds;
+    }
+
+    private List<Long> createBackendsWithUrgentDiskUsage() {
+        List<Long> beIds = Lists.newArrayList();
+        for (int i = 0; i < 4; i++) {
+            long beId = FIRST_BE_ID + i;
+            long usedCapacity = i == 3 ? 700 * GB : 100 * GB;
+            systemInfoService.addBackend(
+                    RebalancerTestUtil.createBackend(beId, BE_TOTAL_CAPACITY, 
usedCapacity));
+            beIds.add(beId);
+        }
+        return beIds;
+    }
+
+    private OlapTable createTable(long tableId, String name, long partitionId) 
{
+        OlapTable table = new OlapTable(tableId, name, new ArrayList<>(), 
KeysType.DUP_KEYS,
+                new RangePartitionInfo(), new HashDistributionInfo());
+        db.registerTable(table);
+
+        MaterializedIndex index = new MaterializedIndex(table.getId(), null);
+        Partition partition = new Partition(partitionId, "p0", index, new 
HashDistributionInfo());
+        table.addPartition(partition);
+        table.getPartitionInfo().addPartition(partitionId, new 
DataProperty(TStorageMedium.HDD),
+                ReplicaAllocation.DEFAULT_ALLOCATION, false, true);
+        table.setIndexMeta(index.getId(), name, Lists.newArrayList(new 
Column()),
+                0, 0, (short) 0, TStorageType.COLUMN, KeysType.DUP_KEYS);
+        return table;
+    }
+
+    /** Distribute single-replica tablets round-robin, exactly like 
createTablets() does. */
+    private MaterializedIndex createRoundRobinTable(long tableId, String name, 
long partitionId,
+            int firstTabletId, int tabletNum, List<Long> beIds, long 
replicaSize) {
+        OlapTable table = createTable(tableId, name, partitionId);
+        for (int i = 0; i < tabletNum; i++) {
+            long beId = beIds.get(i % beIds.size());
+            RebalancerTestUtil.createTablet(invertedIndex, db, table, "p0", 
TStorageMedium.HDD,
+                    firstTabletId + i, Lists.newArrayList(beId), 
Lists.newArrayList(replicaSize));
+        }
+        return table.getPartition(partitionId).getBaseIndex();
+    }
+
+    private LoadStatisticForTag newLoadStatistic(Rebalancer rebalancer) {
+        LoadStatisticForTag stat = new LoadStatisticForTag(
+                Tag.DEFAULT_BACKEND_TAG, systemInfoService, invertedIndex, 
rebalancer);
+        stat.init();
+        return stat;
+    }
+
+    private Map<Long, PathSlot> createWorkingSlots() {
+        Map<Long, PathSlot> slots = Maps.newHashMap();
+        for (long beId : systemInfoService.getAllBackendIds(false)) {
+            Backend be = systemInfoService.getBackend(beId);
+            Map<Long, TStorageMedium> paths = Maps.newHashMap();
+            be.getDisks().values().forEach(disk -> 
paths.put(disk.getPathHash(), disk.getStorageMedium()));
+            slots.put(beId, new PathSlot(paths, beId));
+        }
+        return slots;
+    }
+
+    /**
+     * Drive BeLoadRebalancer the way TabletScheduler does: select alternative 
tablets, complete the
+     * sched ctx (which picks src replica + dest BE), then apply the move. 
Statistics are rebuilt
+     * every round, so the loop stops as soon as the cluster is considered 
balanced.
+     *
+     * @return total number of applied moves
+     */
+    private int runBalanceUntilStable(List<MaterializedIndex> indexes, int 
maxRounds) {
+        int totalMoves = 0;
+        for (int round = 0; round < maxRounds; round++) {
+            Map<Long, PathSlot> slots = createWorkingSlots();
+            BeLoadRebalancer rebalancer = new 
BeLoadRebalancer(systemInfoService, invertedIndex, slots);
+            rebalancer.setSchedulerStat(schedulerStat);
+            Map<Tag, LoadStatisticForTag> statisticMap = Maps.newHashMap();
+            statisticMap.put(Tag.DEFAULT_BACKEND_TAG, 
newLoadStatistic(rebalancer));
+            rebalancer.updateLoadStatistic(statisticMap);
+
+            List<TabletSchedCtx> candidates = 
rebalancer.selectAlternativeTablets();
+            if (candidates.isEmpty()) {
+                LOG.info("round {}: cluster is balanced, no more candidates", 
round);
+                break;
+            }
+
+            int movesThisRound = 0;
+            for (TabletSchedCtx tabletCtx : candidates) {
+                Tablet tablet = findTablet(indexes, tabletCtx.getTabletId());
+                if (tablet == null) {
+                    continue;
+                }
+                tabletCtx.setTablet(tablet);
+                tabletCtx.updateTabletSize();
+                tabletCtx.setStorageMedium(TStorageMedium.HDD);
+                try {
+                    rebalancer.completeSchedCtx(tabletCtx);
+                } catch (SchedException e) {
+                    LOG.debug("tablet {} not scheduled: {}", 
tabletCtx.getTabletId(), e.getMessage());
+                    continue;
+                }
+                applyMove(tablet, tabletCtx.getSrcBackendId(), 
tabletCtx.getDestBackendId());
+                movesThisRound++;
+            }
+
+            totalMoves += movesThisRound;
+            if (movesThisRound == 0) {
+                LOG.info("round {}: no move was accepted, stop", round);
+                break;
+            }
+        }
+        return totalMoves;
+    }
+
+    private Tablet findTablet(List<MaterializedIndex> indexes, long tabletId) {
+        for (MaterializedIndex index : indexes) {
+            Tablet tablet = index.getTablet(tabletId);
+            if (tablet != null) {
+                return tablet;
+            }
+        }
+        return null;
+    }
+
+    /** Simulate a finished clone + redundant replica deletion. */
+    private void applyMove(Tablet tablet, long srcBeId, long destBeId) {
+        TabletMeta tabletMeta = invertedIndex.getTabletMeta(tablet.getId());
+        Replica srcReplica = tablet.getReplicaByBackendId(srcBeId);
+        Assert.assertNotNull(srcReplica);
+        long dataSize = srcReplica.getDataSize();
+
+        Replica destReplica = new LocalReplica(nextId++, destBeId, 
Replica.ReplicaState.NORMAL,
+                srcReplica.getVersion(), tabletMeta.getOldSchemaHash());
+        destReplica.setPathHash(destBeId);
+        destReplica.setDataSize(dataSize);
+
+        // deleteReplicaByBackendId updates the inverted index by itself
+        tablet.deleteReplicaByBackendId(srcBeId);
+        tablet.addReplica(destReplica, true);
+        invertedIndex.addReplica(tablet.getId(), destReplica);
+    }
+
+    private Map<Long, Integer> countReplicaPerBe(MaterializedIndex index) {
+        Map<Long, Integer> counts = Maps.newHashMap();
+        systemInfoService.getAllBackendIds(false).forEach(beId -> 
counts.put(beId, 0));
+        for (Tablet tablet : index.getTablets()) {
+            for (Replica replica : tablet.getReplicas()) {
+                counts.merge(replica.getBackendIdWithoutException(), 1, 
Integer::sum);
+            }
+        }
+        return counts;
+    }
+
+    private void setReplicaSizes(MaterializedIndex index, long dataSize) {
+        for (Tablet tablet : index.getTablets()) {
+            tablet.getReplicas().forEach(replica -> 
replica.setDataSize(dataSize));
+        }
+    }
+
+    private int skewOf(Map<Long, Integer> counts) {
+        return Collections.max(counts.values()) - 
Collections.min(counts.values());
+    }
+
+    private List<Integer> sortedCounts(Map<Long, Integer> counts) {
+        List<Integer> values = Lists.newArrayList(counts.values());
+        Collections.sort(values);
+        return values;
+    }
+}


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

Reply via email to