This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.0 by this push:
new 2bc3248a335 [improvement](cloud) Use rendezvous hashing for colocate
tablet placement (#64638) (#66067)
2bc3248a335 is described below
commit 2bc3248a335d3e086ffb6c1b30422adc12abca82
Author: deardeng <[email protected]>
AuthorDate: Mon Jul 27 22:27:47 2026 +0800
[improvement](cloud) Use rendezvous hashing for colocate tablet placement
(#64638) (#66067)
pick from https://github.com/apache/doris/pull/64638
---
.../main/java/org/apache/doris/common/Config.java | 6 +
.../apache/doris/catalog/ColocateTableIndex.java | 3 +
.../cloud/catalog/CloudColocatePlacement.java | 64 ++++++
.../apache/doris/cloud/catalog/CloudReplica.java | 54 ++++-
.../doris/cloud/system/CloudSystemInfoService.java | 144 +++++++++++++
.../org/apache/doris/system/SystemInfoService.java | 4 +
.../cloud/catalog/CloudColocatePlacementTest.java | 232 +++++++++++++++++++++
.../test_cloud_colocate_consistent_hash_join.out | 5 +
...test_cloud_colocate_consistent_hash_join.groovy | 67 ++++++
9 files changed, 570 insertions(+), 9 deletions(-)
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 04d8dd375b9..5b0d5245b47 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
@@ -3649,6 +3649,12 @@ public class Config extends ConfigBase {
description = { "存算分离模式下,一个 BE 挂掉多长时间后,它的 tablet 彻底转移到其他 BE 上" })
public static int rehash_tablet_after_be_dead_seconds = 3600;
+ @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."})
+ public static boolean enable_cloud_colocate_consistent_hash = true;
+
@ConfField(mutable = true, description = {"存算分离模式下是否启用自动启停功能,默认 true",
"Whether to enable the automatic start-stop feature in cloud model,
default is true."})
public static boolean enable_auto_start_for_cloud_cluster = true;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/catalog/ColocateTableIndex.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/ColocateTableIndex.java
index 6bf1cf2e19b..7095ffa4969 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/ColocateTableIndex.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/ColocateTableIndex.java
@@ -346,6 +346,9 @@ public class ColocateTableIndex implements Writable {
group2Schema.remove(groupId);
group2ErrMsgs.remove(groupId);
unstableGroups.remove(groupId);
+ if (Config.isCloudMode()) {
+
Env.getCurrentSystemInfo().invalidateCloudColocatePlacement(groupId);
+ }
String fullGroupName = null;
for (Map.Entry<String, GroupId> entry :
groupName2Id.entrySet()) {
if (entry.getValue().equals(groupId)) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudColocatePlacement.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudColocatePlacement.java
new file mode 100644
index 00000000000..baec8f4b0e3
--- /dev/null
+++
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudColocatePlacement.java
@@ -0,0 +1,64 @@
+// 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.cloud.catalog;
+
+import com.google.common.base.Preconditions;
+import com.google.common.hash.Hashing;
+
+import java.util.Arrays;
+
+public class CloudColocatePlacement {
+ @FunctionalInterface
+ interface ScoreFunction {
+ long score(long grpId, long idx, long beId);
+ }
+
+ private CloudColocatePlacement() {
+ }
+
+ public static long score(long grpId, long idx, long beId) {
+ return Hashing.murmur3_128().newHasher()
+ .putLong(grpId)
+ .putLong(idx)
+ .putLong(beId)
+ .hash()
+ .asLong();
+ }
+
+ public static long pickBackendId(long grpId, long idx, long[]
candidateBeIds) {
+ return pickBackendId(grpId, idx, candidateBeIds,
CloudColocatePlacement::score);
+ }
+
+ static long pickBackendId(long grpId, long idx, long[] candidateBeIds,
ScoreFunction scoreFunction) {
+ Preconditions.checkArgument(candidateBeIds.length > 0);
+ long[] sortedBeIds = Arrays.copyOf(candidateBeIds,
candidateBeIds.length);
+ Arrays.sort(sortedBeIds);
+
+ long pickedBeId = sortedBeIds[0];
+ long maxScore = scoreFunction.score(grpId, idx, pickedBeId);
+ for (int i = 1; i < sortedBeIds.length; i++) {
+ long beId = sortedBeIds[i];
+ long score = scoreFunction.score(grpId, idx, beId);
+ if (score > maxScore || (score == maxScore && beId < pickedBeId)) {
+ maxScore = score;
+ pickedBeId = beId;
+ }
+ }
+ return pickedBeId;
+ }
+}
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 1e109c3350e..5050f4e6e63 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
@@ -114,6 +114,16 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
return Env.getCurrentColocateIndex().isColocateTableNoLock(tableId);
}
+ private boolean isDecommissioningOrDecommissioned(Backend be) {
+ boolean decommissioning = be.isDecommissioning();
+ boolean decommissioned = be.isDecommissioned();
+ if ((decommissioning || decommissioned) && LOG.isDebugEnabled()) {
+ LOG.debug("backend {} is filtered by decommission state,
decommissioning={}, decommissioned={}, "
+ + "backend={}", be.getId(), decommissioning,
decommissioned, be);
+ }
+ return decommissioning || decommissioned;
+ }
+
public long getColocatedBeId(String clusterId) throws
ComputeGroupException {
CloudSystemInfoService infoService = ((CloudSystemInfoService)
Env.getCurrentSystemInfo());
List<Backend> bes =
infoService.getBackendsByClusterId(clusterId).stream()
@@ -148,28 +158,54 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
}
GroupId groupId =
Env.getCurrentColocateIndex().getGroupNoLock(tableId);
- HashCode hashCode = Hashing.murmur3_128().hashLong(groupId.grpId);
if (availableBes.size() != bes.size()) {
- // some be is dead recently, still hash tablets on all backends.
long needRehashDeadTime = System.currentTimeMillis() -
Config.rehash_tablet_after_be_dead_seconds * 1000L;
if (bes.stream().anyMatch(be -> !be.isAlive() &&
be.getLastUpdateMs() > needRehashDeadTime)) {
List<Backend> beAliveOrDeadShort = bes.stream()
.filter(be -> be.isAlive() || be.getLastUpdateMs() >
needRehashDeadTime)
.collect(Collectors.toList());
- long index = getIndexByBeNum(hashCode.asLong() + idx,
beAliveOrDeadShort.size());
- Backend be = beAliveOrDeadShort.get((int) index);
- if (be.isAlive() && !be.isDecommissioned()) {
+ Backend be = pickColocatedBackendForDeadGrace(infoService,
groupId, clusterId, beAliveOrDeadShort);
+ if (be.isAlive() && !isDecommissioningOrDecommissioned(be)) {
return be.getId();
}
}
}
- // Tablets with the same idx will be hashed to the same BE, which
- // meets the requirements of colocated table.
+ return pickColocatedBackend(infoService, groupId, clusterId,
availableBes).getId();
+ }
+
+ private Backend pickColocatedBackendForDeadGrace(CloudSystemInfoService
infoService, GroupId groupId,
+ String clusterId, List<Backend> availableBes) {
+ if (!Config.enable_cloud_colocate_consistent_hash) {
+ return pickColocatedBackend(infoService, groupId, clusterId,
availableBes);
+ }
+ int bucketNum = infoService.getCloudColocateBucketsNum(groupId);
+ CloudSystemInfoService.checkCloudColocateBucketIdx(groupId, clusterId,
idx, bucketNum);
+ long[] availableBeIds =
availableBes.stream().mapToLong(Backend::getId).toArray();
+ long pickedBeId = CloudColocatePlacement.pickBackendId(groupId.grpId,
idx, availableBeIds);
+ return findPickedBackend(pickedBeId, groupId, clusterId, availableBes);
+ }
+
+ Backend pickColocatedBackend(CloudSystemInfoService infoService, GroupId
groupId, String clusterId,
+ List<Backend> availableBes) {
+ if (Config.enable_cloud_colocate_consistent_hash) {
+ List<Long> availableBeIds =
availableBes.stream().map(Backend::getId).collect(Collectors.toList());
+ long pickedBeId = infoService.getCloudColocateHrwBeId(groupId,
clusterId, availableBeIds, idx);
+ return findPickedBackend(pickedBeId, groupId, clusterId,
availableBes);
+ }
+
+ HashCode hashCode = Hashing.murmur3_128().hashLong(groupId.grpId);
long index = getIndexByBeNum(hashCode.asLong() + idx,
availableBes.size());
- long pickedBeId = availableBes.get((int) index).getId();
+ return availableBes.get((int) index);
+ }
- return pickedBeId;
+ private Backend findPickedBackend(long pickedBeId, GroupId groupId, String
clusterId, List<Backend> availableBes) {
+ return availableBes.stream().filter(be -> be.getId() ==
pickedBeId).findFirst()
+ .orElseThrow(() -> new IllegalStateException(String.format(
+ "picked colocate backend %s is not in candidate set,
group %s, cluster %s, bucket idx %s, "
+ + "candidate backend ids %s",
+ pickedBeId, groupId, clusterId, idx,
+
availableBes.stream().map(Backend::getId).collect(Collectors.toList()))));
}
@Override
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
index a60d5d46e3f..c334db2b824 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
@@ -20,8 +20,11 @@ package org.apache.doris.cloud.system;
import org.apache.doris.analysis.ModifyBackendClause;
import org.apache.doris.analysis.ModifyBackendHostNameClause;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.ColocateGroupSchema;
+import org.apache.doris.catalog.ColocateTableIndex.GroupId;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.ReplicaAllocation;
+import org.apache.doris.cloud.catalog.CloudColocatePlacement;
import org.apache.doris.cloud.catalog.CloudEnv;
import org.apache.doris.cloud.catalog.ComputeGroup;
import org.apache.doris.cloud.proto.Cloud;
@@ -53,6 +56,7 @@ import org.apache.doris.system.Frontend;
import org.apache.doris.system.SystemInfoService;
import org.apache.doris.thrift.TStorageMedium;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
@@ -69,6 +73,7 @@ import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -99,8 +104,145 @@ public class CloudSystemInfoService extends
SystemInfoService {
// clusterId -> ComputeGroup
protected Map<String, ComputeGroup> computeGroupIdToComputeGroup = new
ConcurrentHashMap<>();
+ private final Map<ColocatePlacementKey, ColocatePlacementCache>
colocatePlacementCache =
+ new ConcurrentHashMap<>();
+
private InstanceInfoPB.Status instanceStatus;
+ public long getCloudColocateHrwBeId(GroupId groupId, String clusterId,
List<Long> availableBeIds, long idx) {
+ return getCloudColocateHrwBeIdInternal(groupId, clusterId,
availableBeIds, idx, -1);
+ }
+
+ @VisibleForTesting
+ public long getCloudColocateHrwBeIdForTest(GroupId groupId, String
clusterId, List<Long> availableBeIds,
+ long idx, int bucketNumForTest) {
+ return getCloudColocateHrwBeIdInternal(groupId, clusterId,
availableBeIds, idx, bucketNumForTest);
+ }
+
+ private long getCloudColocateHrwBeIdInternal(GroupId groupId, String
clusterId, List<Long> availableBeIds,
+ long idx, int bucketNumForTest) {
+ long[] candidateBeIds =
availableBeIds.stream().mapToLong(Long::longValue).toArray();
+ ColocatePlacementKey key = new ColocatePlacementKey(groupId,
clusterId);
+ long fingerprint = fingerprintBackendIds(candidateBeIds);
+ ColocatePlacementCache cache = colocatePlacementCache.get(key);
+ if (cache != null && cache.same(fingerprint)) {
+ checkCloudColocateBucketIdx(groupId, clusterId, idx,
cache.bucketNum);
+ return cache.beIdByBucket[(int) idx];
+ }
+
+ // Resolve bucketNum BEFORE compute(): getColocateBucketsNum acquires
the colocate-index
+ // read lock, while removeTable() evicts this cache holding the
colocate-index write lock.
+ // Acquiring the colocate lock inside the ConcurrentHashMap compute()
bin lock would invert
+ // that order and risk an ABBA deadlock, so the locked fetch must stay
outside compute().
+ int bucketNum = bucketNumForTest > 0 ? bucketNumForTest :
getColocateBucketsNum(groupId);
+ cache = colocatePlacementCache.compute(key, (ignored, oldCache) -> {
+ if (oldCache != null && oldCache.same(fingerprint, bucketNum)) {
+ return oldCache;
+ }
+ return ColocatePlacementCache.build(fingerprint, candidateBeIds,
groupId.grpId, bucketNum);
+ });
+ checkCloudColocateBucketIdx(groupId, clusterId, idx, cache.bucketNum);
+ return cache.beIdByBucket[(int) idx];
+ }
+
+ private static long fingerprintBackendIds(long[] beIds) {
+ long sum = 0;
+ for (long beId : beIds) {
+ sum += mix64(beId);
+ }
+ return mix64(sum) ^ mix64(beIds.length);
+ }
+
+ private static long mix64(long value) {
+ value ^= value >>> 33;
+ value *= 0xff51afd7ed558ccdL;
+ value ^= value >>> 33;
+ value *= 0xc4ceb9fe1a85ec53L;
+ value ^= value >>> 33;
+ return value;
+ }
+
+ private static int getColocateBucketsNum(GroupId groupId) {
+ ColocateGroupSchema groupSchema =
Env.getCurrentColocateIndex().getGroupSchema(groupId);
+ Preconditions.checkState(groupSchema != null, "missing colocate group
schema for group %s", groupId);
+ return groupSchema.getBucketsNum();
+ }
+
+ public int getCloudColocateBucketsNum(GroupId groupId) {
+ return getColocateBucketsNum(groupId);
+ }
+
+ public static void checkCloudColocateBucketIdx(GroupId groupId, String
clusterId, long idx, int bucketNum) {
+ if (idx < 0 || idx >= bucketNum) {
+ throw new IllegalStateException(String.format(
+ "colocate bucket idx %s is outside bucket num %s for group
%s, cluster %s",
+ idx, bucketNum, groupId, clusterId));
+ }
+ }
+
+ @Override
+ public void invalidateCloudColocatePlacement(GroupId groupId) {
+ colocatePlacementCache.keySet().removeIf(key ->
key.groupId.equals(groupId));
+ }
+
+ public void invalidateCloudColocatePlacement(String clusterId) {
+ colocatePlacementCache.keySet().removeIf(key ->
key.clusterId.equals(clusterId));
+ }
+
+ private static class ColocatePlacementKey {
+ private final GroupId groupId;
+ private final String clusterId;
+
+ private ColocatePlacementKey(GroupId groupId, String clusterId) {
+ this.groupId = groupId;
+ this.clusterId = clusterId;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof ColocatePlacementKey)) {
+ return false;
+ }
+ ColocatePlacementKey other = (ColocatePlacementKey) obj;
+ return groupId.equals(other.groupId) &&
clusterId.equals(other.clusterId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(groupId, clusterId);
+ }
+ }
+
+ private static class ColocatePlacementCache {
+ private final long fingerprint;
+ private final int bucketNum;
+ private final long[] beIdByBucket;
+
+ private ColocatePlacementCache(long fingerprint, int bucketNum, long[]
beIdByBucket) {
+ this.fingerprint = fingerprint;
+ this.bucketNum = bucketNum;
+ this.beIdByBucket = beIdByBucket;
+ }
+
+ private static ColocatePlacementCache build(long fingerprint, long[]
candidateBeIds, long grpId,
+ int bucketNum) {
+ long[] beIdByBucket = new long[bucketNum];
+ for (int i = 0; i < bucketNum; i++) {
+ beIdByBucket[i] = CloudColocatePlacement.pickBackendId(grpId,
i, candidateBeIds);
+ }
+ return new ColocatePlacementCache(fingerprint, bucketNum,
beIdByBucket);
+ }
+
+ private boolean same(long otherFingerprint) {
+ return fingerprint == otherFingerprint;
+ }
+
+ private boolean same(long otherFingerprint, int otherBucketNum) {
+ return fingerprint == otherFingerprint && bucketNum ==
otherBucketNum;
+ }
+
+ }
+
public void addVirtualClusterInfoToMapsNoLock(String clusterId, String
clusterName) {
LOG.info("add virtual cluster info to maps, clusterId={},
clusterName={}", clusterId, clusterName);
clusterNameToId.put(clusterName, clusterId);
@@ -286,6 +428,7 @@ public class CloudSystemInfoService extends
SystemInfoService {
wlock.lock();
computeGroupIdToComputeGroup.remove(computeGroupId);
removeVirtualClusterInfoFromMapsNoLock(computeGroupId,
computeGroupName);
+ invalidateCloudColocatePlacement(computeGroupId);
} finally {
wlock.unlock();
}
@@ -1119,6 +1262,7 @@ public class CloudSystemInfoService extends
SystemInfoService {
try {
clusterNameToId.remove(clusterName, clusterId);
clusterIdToBackend.remove(clusterId);
+ invalidateCloudColocatePlacement(clusterId);
} finally {
wlock.unlock();
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
b/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
index 4f1ddd23343..e36b9687fe9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java
@@ -19,6 +19,7 @@ package org.apache.doris.system;
import org.apache.doris.analysis.ModifyBackendClause;
import org.apache.doris.analysis.ModifyBackendHostNameClause;
+import org.apache.doris.catalog.ColocateTableIndex.GroupId;
import org.apache.doris.catalog.DiskInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.ReplicaAllocation;
@@ -94,6 +95,9 @@ public class SystemInfoService {
private volatile ImmutableMap<Long, DiskInfo> pathHashToDiskInfoRef =
ImmutableMap.of();
+ public void invalidateCloudColocatePlacement(GroupId groupId) {
+ }
+
public static class HostInfo implements Comparable<HostInfo> {
public String host;
public int port;
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudColocatePlacementTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudColocatePlacementTest.java
new file mode 100644
index 00000000000..c04bd13bff7
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudColocatePlacementTest.java
@@ -0,0 +1,232 @@
+// 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.cloud.catalog;
+
+import org.apache.doris.catalog.ColocateTableIndex.GroupId;
+import org.apache.doris.catalog.Replica.ReplicaState;
+import org.apache.doris.cloud.system.CloudSystemInfoService;
+import org.apache.doris.common.Config;
+import org.apache.doris.system.Backend;
+
+import com.google.common.hash.HashCode;
+import com.google.common.hash.Hashing;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class CloudColocatePlacementTest {
+ private final boolean oldEnableCloudColocateConsistentHash =
Config.enable_cloud_colocate_consistent_hash;
+
+ @AfterEach
+ public void tearDown() {
+ Config.enable_cloud_colocate_consistent_hash =
oldEnableCloudColocateConsistentHash;
+ }
+
+ @Test
+ public void testAddAndRemoveOneBackendMovesFarFewerBucketsThanModulo() {
+ long groupId = 100L;
+ int bucketNum = 4096;
+ long[] originalBeIds = range(1, 16);
+ long[] addedBeIds = range(1, 17);
+ long[] removedBeIds = range(1, 15);
+
+ int hrwAddMoved = changedBucketsByHrw(groupId, bucketNum,
originalBeIds, addedBeIds);
+ int hrwRemoveMoved = changedBucketsByHrw(groupId, bucketNum,
originalBeIds, removedBeIds);
+ int moduloAddMoved = changedBucketsByModulo(groupId, bucketNum,
originalBeIds, addedBeIds);
+ int moduloRemoveMoved = changedBucketsByModulo(groupId, bucketNum,
originalBeIds, removedBeIds);
+
+ Assertions.assertTrue(hrwAddMoved < bucketNum / 8,
+ "HRW should move about 1/N buckets after adding one BE, but
moved " + hrwAddMoved);
+ Assertions.assertTrue(hrwRemoveMoved < bucketNum / 8,
+ "HRW should move about 1/N buckets after removing one BE, but
moved " + hrwRemoveMoved);
+ Assertions.assertTrue(moduloAddMoved > bucketNum * 9 / 10,
+ "Modulo should move almost all buckets after adding one BE,
but moved " + moduloAddMoved);
+ Assertions.assertTrue(moduloRemoveMoved > bucketNum * 9 / 10,
+ "Modulo should move almost all buckets after removing one BE,
but moved " + moduloRemoveMoved);
+ }
+
+ @Test
+ public void testTieBreakPicksSmallerBackendId() {
+ long[] beIds = new long[] {30L, 20L, 10L};
+
+ long pickedBeId = CloudColocatePlacement.pickBackendId(100L, 1L,
beIds, (groupId, idx, beId) -> 1L);
+
+ Assertions.assertEquals(10L, pickedBeId);
+ }
+
+ @Test
+ public void testCacheInvalidatesWhenBackendSetChanges() {
+ CloudSystemInfoService infoService = new CloudSystemInfoService();
+ GroupId groupId = new GroupId(1L, 100L);
+ List<Long> originalBeIds = Arrays.asList(1L, 2L, 3L);
+ List<Long> addedBeIds = Arrays.asList(1L, 2L, 3L, 4L);
+ int bucketNum = 128;
+
+ int changed = 0;
+ for (int idx = 0; idx < bucketNum; idx++) {
+ long original =
infoService.getCloudColocateHrwBeIdForTest(groupId, "cluster0",
+ originalBeIds, idx, bucketNum);
+ long cached = infoService.getCloudColocateHrwBeIdForTest(groupId,
"cluster0",
+ originalBeIds, idx, bucketNum);
+ long afterAdd =
infoService.getCloudColocateHrwBeIdForTest(groupId, "cluster0",
+ addedBeIds, idx, bucketNum);
+ Assertions.assertEquals(original, cached);
+ if (original != afterAdd) {
+ changed++;
+ }
+ }
+
+ Assertions.assertTrue(changed > 0, "Changing BE set should invalidate
cached placement");
+ Assertions.assertTrue(changed < bucketNum / 2, "HRW should not rebuild
as modulo-style reshuffle");
+ }
+
+ @Test
+ public void testCachePlacementSameForSameBackendSetInDifferentOrder() {
+ CloudSystemInfoService infoService = new CloudSystemInfoService();
+ GroupId groupId = new GroupId(1L, 100L);
+ List<Long> originalBeIds = Arrays.asList(1L, 2L, 3L);
+ List<Long> reorderedBeIds = Arrays.asList(3L, 1L, 2L);
+ int bucketNum = 128;
+
+ for (int idx = 0; idx < bucketNum; idx++) {
+ long original =
infoService.getCloudColocateHrwBeIdForTest(groupId, "cluster0",
+ originalBeIds, idx, bucketNum);
+ long reordered =
infoService.getCloudColocateHrwBeIdForTest(groupId, "cluster0",
+ reorderedBeIds, idx, bucketNum);
+ Assertions.assertEquals(original, reordered);
+ }
+ }
+
+ @Test
+ public void testCacheEvictedByClusterId() {
+ CloudSystemInfoService infoService = new CloudSystemInfoService();
+ GroupId groupId = new GroupId(1L, 100L);
+ List<Long> beIds = Arrays.asList(1L, 2L, 3L);
+
+ long original = infoService.getCloudColocateHrwBeIdForTest(groupId,
"cluster0", beIds, 0L, 16);
+ infoService.invalidateCloudColocatePlacement("cluster0");
+ long rebuilt = infoService.getCloudColocateHrwBeIdForTest(groupId,
"cluster0", beIds, 0L, 16);
+
+ Assertions.assertEquals(original, rebuilt);
+ }
+
+ @Test
+ public void testHrwPlacementRejectsInvalidBucketIdxForTest() {
+ CloudSystemInfoService infoService = new CloudSystemInfoService();
+ GroupId groupId = new GroupId(1L, 100L);
+ List<Long> beIds = Arrays.asList(1L, 2L, 3L);
+
+ IllegalStateException exception =
Assertions.assertThrows(IllegalStateException.class,
+ () -> infoService.getCloudColocateHrwBeIdForTest(groupId,
"cluster0", beIds, 8L, 8));
+ Assertions.assertTrue(exception.getMessage().contains("outside bucket
num 8"));
+ }
+
+ @Test
+ public void testDeadGraceHrwPlacementRejectsInvalidBucketIdx() throws
Exception {
+ CloudSystemInfoService infoService = Mockito.spy(new
CloudSystemInfoService());
+ GroupId groupId = new GroupId(1L, 100L);
+ List<Backend> backends = createBackends(1L, 2L, 3L);
+ CloudReplica replica = new CloudReplica(1L, -1L, ReplicaState.NORMAL,
0L, 0, 1L, 2L, 3L, 4L, 8L);
+
Mockito.doReturn(8).when(infoService).getCloudColocateBucketsNum(groupId);
+
+ IllegalStateException exception =
Assertions.assertThrows(IllegalStateException.class,
+ () -> invokePickColocatedBackendForDeadGrace(replica,
infoService, groupId, "cluster0", backends));
+ Assertions.assertTrue(exception.getMessage().contains("outside bucket
num 8"));
+ }
+
+ @Test
+ public void testConfigOffUsesLegacyModuloResult() {
+ Config.enable_cloud_colocate_consistent_hash = false;
+ GroupId groupId = new GroupId(1L, 100L);
+ List<Backend> backends = createBackends(10L, 20L, 30L);
+ CloudReplica replica = new CloudReplica(1L, -1L, ReplicaState.NORMAL,
0L, 0, 1L, 2L, 3L, 4L, 5L);
+
+ long pickedBeId = replica.pickColocatedBackend(new
CloudSystemInfoService(), groupId, "cluster0", backends)
+ .getId();
+ long expectedBeId = pickModulo(groupId.grpId, 5L,
backends.stream().mapToLong(Backend::getId).toArray());
+
+ Assertions.assertEquals(expectedBeId, pickedBeId);
+ }
+
+ private static long[] range(int fromInclusive, int toExclusive) {
+ long[] values = new long[toExclusive - fromInclusive];
+ for (int i = 0; i < values.length; i++) {
+ values[i] = fromInclusive + i;
+ }
+ return values;
+ }
+
+ private static int changedBucketsByHrw(long groupId, int bucketNum, long[]
originalBeIds, long[] changedBeIds) {
+ int changed = 0;
+ for (int idx = 0; idx < bucketNum; idx++) {
+ long before = CloudColocatePlacement.pickBackendId(groupId, idx,
originalBeIds);
+ long after = CloudColocatePlacement.pickBackendId(groupId, idx,
changedBeIds);
+ if (before != after) {
+ changed++;
+ }
+ }
+ return changed;
+ }
+
+ private static int changedBucketsByModulo(long groupId, int bucketNum,
long[] originalBeIds, long[] changedBeIds) {
+ int changed = 0;
+ for (int idx = 0; idx < bucketNum; idx++) {
+ long before = pickModulo(groupId, idx, originalBeIds);
+ long after = pickModulo(groupId, idx, changedBeIds);
+ if (before != after) {
+ changed++;
+ }
+ }
+ return changed;
+ }
+
+ private static long pickModulo(long groupId, long idx, long[] beIds) {
+ HashCode hashCode = Hashing.murmur3_128().hashLong(groupId);
+ long index = (hashCode.asLong() + idx) % beIds.length;
+ index = (index + beIds.length) % beIds.length;
+ return beIds[(int) index];
+ }
+
+ private static Backend invokePickColocatedBackendForDeadGrace(CloudReplica
replica,
+ CloudSystemInfoService infoService, GroupId groupId, String
clusterId, List<Backend> backends)
+ throws Throwable {
+ Method method =
CloudReplica.class.getDeclaredMethod("pickColocatedBackendForDeadGrace",
+ CloudSystemInfoService.class, GroupId.class, String.class,
List.class);
+ method.setAccessible(true);
+ try {
+ return (Backend) method.invoke(replica, infoService, groupId,
clusterId, backends);
+ } catch (InvocationTargetException e) {
+ throw e.getCause();
+ }
+ }
+
+ private static List<Backend> createBackends(long... beIds) {
+ List<Backend> backends = new ArrayList<>();
+ for (long beId : beIds) {
+ backends.add(new Backend(beId, "127.0.0." + beId, 9050));
+ }
+ return backends;
+ }
+}
diff --git
a/regression-test/data/cloud_p0/multi_cluster/test_cloud_colocate_consistent_hash_join.out
b/regression-test/data/cloud_p0/multi_cluster/test_cloud_colocate_consistent_hash_join.out
new file mode 100644
index 00000000000..869f407b9f3
--- /dev/null
+++
b/regression-test/data/cloud_p0/multi_cluster/test_cloud_colocate_consistent_hash_join.out
@@ -0,0 +1,5 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !join --
+1 10 100
+2 20 200
+4 40 400
diff --git
a/regression-test/suites/cloud_p0/multi_cluster/test_cloud_colocate_consistent_hash_join.groovy
b/regression-test/suites/cloud_p0/multi_cluster/test_cloud_colocate_consistent_hash_join.groovy
new file mode 100644
index 00000000000..76070a93017
--- /dev/null
+++
b/regression-test/suites/cloud_p0/multi_cluster/test_cloud_colocate_consistent_hash_join.groovy
@@ -0,0 +1,67 @@
+// 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
+
+suite("test_cloud_colocate_consistent_hash_join", "multi_cluster,docker") {
+ def options = new ClusterOptions()
+ options.setFeNum(1)
+ options.setBeNum(3)
+ options.cloudMode = true
+ options.feConfigs += [
+ "enable_cloud_colocate_consistent_hash=true"
+ ]
+
+ docker(options) {
+ sql "DROP TABLE IF EXISTS test_cloud_colocate_consistent_hash_join_t1"
+ sql "DROP TABLE IF EXISTS test_cloud_colocate_consistent_hash_join_t2"
+
+ sql """
+ CREATE TABLE test_cloud_colocate_consistent_hash_join_t1 (
+ k INT,
+ v1 INT
+ )
+ DISTRIBUTED BY HASH(k) BUCKETS 8
+ PROPERTIES (
+ "replication_num" = "1",
+ "colocate_with" =
"test_cloud_colocate_consistent_hash_join_group"
+ )
+ """
+
+ sql """
+ CREATE TABLE test_cloud_colocate_consistent_hash_join_t2 (
+ k INT,
+ v2 INT
+ )
+ DISTRIBUTED BY HASH(k) BUCKETS 8
+ PROPERTIES (
+ "replication_num" = "1",
+ "colocate_with" =
"test_cloud_colocate_consistent_hash_join_group"
+ )
+ """
+
+ sql "INSERT INTO test_cloud_colocate_consistent_hash_join_t1 VALUES
(1, 10), (2, 20), (3, 30), (4, 40)"
+ sql "INSERT INTO test_cloud_colocate_consistent_hash_join_t2 VALUES
(1, 100), (2, 200), (4, 400), (5, 500)"
+
+ order_qt_join """
+ SELECT t1.k, t1.v1, t2.v2
+ FROM test_cloud_colocate_consistent_hash_join_t1 t1
+ JOIN test_cloud_colocate_consistent_hash_join_t2 t2 ON t1.k = t2.k
+ ORDER BY t1.k
+ """
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]