Repository: skiptests/astraea (java)

Issue:
Information loss when using `Broker` in conjunction with `NodeInfo`
Context: https://github.com/skiptests/astraea/pull/1184#discussion_r1034931675

`NodeInfo` 本身有設計成能當資料結構的 key 使用，另外 `Broker` 是 `NodeInfo` 的子類別，因此 `Broker` 本身也可以當做 key 使用。當 `Broker` 和 `NodeInfo` 混在一起使用時，有機會 `NodeInfo` 會在資料結構中覆蓋過 `Broker`，使 `Broker` 的訊息消失。

嘗試修正這個問題，避免 `Broker` 和 `NodeInfo` 一起使用時造成的訊息丟失。

Pull request description:
Remove `Comparable` interface from `NodeInfo`
Resolve #1189, #1185.

* 移除 `NodeInfo` 的 `Comparable` 界面
* `ClusterInfo` 內使用 `List` 儲存 `Node`
* `Clusterinfo#of(List)` 移動到 test scope.

Patch:
diff --git a/common/src/main/java/org/astraea/common/admin/Admin.java b/common/src/main/java/org/astraea/common/admin/Admin.java
index 2e36ccea..a92dc844 100644
--- a/common/src/main/java/org/astraea/common/admin/Admin.java
+++ b/common/src/main/java/org/astraea/common/admin/Admin.java
@@ -144,7 +144,7 @@ public interface Admin extends AutoCloseable {
   /**
    * @return online node information
    */
-  CompletionStage<Set<NodeInfo>> nodeInfos();
+  CompletionStage<List<NodeInfo>> nodeInfos();
 
   /**
    * @return online broker information
diff --git a/common/src/main/java/org/astraea/common/admin/AdminImpl.java b/common/src/main/java/org/astraea/common/admin/AdminImpl.java
index d2effafe..19330825 100644
--- a/common/src/main/java/org/astraea/common/admin/AdminImpl.java
+++ b/common/src/main/java/org/astraea/common/admin/AdminImpl.java
@@ -440,11 +440,10 @@ class AdminImpl implements Admin {
   }
 
   @Override
-  public CompletionStage<Set<NodeInfo>> nodeInfos() {
+  public CompletionStage<List<NodeInfo>> nodeInfos() {
     return to(kafkaAdmin.describeCluster().nodes())
         .thenApply(
-            nodes ->
-                nodes.stream().map(NodeInfo::of).collect(Collectors.toCollection(TreeSet::new)));
+            nodes -> nodes.stream().map(NodeInfo::of).collect(Collectors.toUnmodifiableList()));
   }
 
   @Override
@@ -591,7 +590,7 @@ class AdminImpl implements Admin {
                 brokers ->
                     brokers.stream()
                         .map(x -> (NodeInfo) x)
-                        .collect(Collectors.toUnmodifiableSet())),
+                        .collect(Collectors.toUnmodifiableList())),
         replicas(topics),
         ClusterInfo::of);
   }
diff --git a/common/src/main/java/org/astraea/common/admin/ClusterInfo.java b/common/src/main/java/org/astraea/common/admin/ClusterInfo.java
index bbbf9505..b5b4b56a 100644
--- a/common/src/main/java/org/astraea/common/admin/ClusterInfo.java
+++ b/common/src/main/java/org/astraea/common/admin/ClusterInfo.java
@@ -34,7 +34,7 @@ import org.astraea.common.Lazy;
 
 public interface ClusterInfo<T extends ReplicaInfo> {
   static <T extends ReplicaInfo> ClusterInfo<T> empty() {
-    return of(Set.of(), List.of());
+    return of(List.of(), List.of());
   }
 
   // ---------------------[helpers]---------------------//
@@ -72,7 +72,7 @@ public interface ClusterInfo<T extends ReplicaInfo> {
   /** Mask specific topics from a {@link ClusterInfo}. */
   static <T extends ReplicaInfo> ClusterInfo<T> masked(
       ClusterInfo<T> clusterInfo, Predicate<String> topicFilter) {
-    final var nodes = Set.copyOf(clusterInfo.nodes());
+    final var nodes = List.copyOf(clusterInfo.nodes());
     final var replicas =
         clusterInfo
             .replicaStream()
@@ -212,34 +212,13 @@ public interface ClusterInfo<T extends ReplicaInfo> {
    */
   static ClusterInfo<ReplicaInfo> of(org.apache.kafka.common.Cluster cluster) {
     return of(
-        cluster.nodes().stream().map(NodeInfo::of).collect(Collectors.toUnmodifiableSet()),
+        cluster.nodes().stream().map(NodeInfo::of).collect(Collectors.toUnmodifiableList()),
         cluster.topics().stream()
             .flatMap(t -> cluster.partitionsForTopic(t).stream())
             .flatMap(p -> ReplicaInfo.of(p).stream())
             .collect(Collectors.toUnmodifiableList()));
   }
 
-  /**
-   * build a cluster info based on replicas. Noted that the node info are collected by the replicas.
-   *
-   * <p>Be aware that the <code>replicas</code> parameter describes <strong>the replica lists of a
-   * subset of topic/partitions</strong>. It doesn't require the topic/partition part to have
-   * cluster-wide complete information. But the replica list has to be complete. Provide a partial
-   * replica list might result in data loss or unintended replica drop during rebalance plan
-   * proposing & execution.
-   *
-   * @param replicas used to build cluster info
-   * @return cluster info
-   * @param <T> ReplicaInfo or Replica
-   */
-  static <T extends ReplicaInfo> ClusterInfo<T> of(List<T> replicas) {
-    // TODO: this method is not suitable for production use. Move it to the test scope.
-    //  see https://github.com/skiptests/astraea/issues/1185
-    return of(
-        replicas.stream().map(ReplicaInfo::nodeInfo).collect(Collectors.toUnmodifiableSet()),
-        replicas);
-  }
-
   /**
    * build a cluster info based on replicas and a set of cluster node information.
    *
@@ -254,7 +233,7 @@ public interface ClusterInfo<T extends ReplicaInfo> {
    * @return cluster info
    * @param <T> ReplicaInfo or Replica
    */
-  static <T extends ReplicaInfo> ClusterInfo<T> of(Set<NodeInfo> nodes, List<T> replicas) {
+  static <T extends ReplicaInfo> ClusterInfo<T> of(List<NodeInfo> nodes, List<T> replicas) {
     return new Optimized<>(nodes, replicas);
   }
 
@@ -471,9 +450,9 @@ public interface ClusterInfo<T extends ReplicaInfo> {
   // ---------------------[abstract methods]---------------------//
 
   /**
-   * @return The known set of nodes
+   * @return The known nodes
    */
-  Set<NodeInfo> nodes();
+  List<NodeInfo> nodes();
 
   /**
    * @return replica stream to offer effective way to operate a bunch of replicas
@@ -482,7 +461,7 @@ public interface ClusterInfo<T extends ReplicaInfo> {
 
   /** It optimizes all queries by pre-allocated Map collection. */
   class Optimized<T extends ReplicaInfo> implements ClusterInfo<T> {
-    private final Set<NodeInfo> nodeInfos;
+    private final List<NodeInfo> nodeInfos;
     private final List<T> all;
 
     private final Lazy<Map<Map.Entry<Integer, String>, List<T>>> byBrokerTopic;
@@ -491,7 +470,7 @@ public interface ClusterInfo<T extends ReplicaInfo> {
     private final Lazy<Map<TopicPartition, List<T>>> byPartition;
     private final Lazy<Map<TopicPartitionReplica, List<T>>> byReplica;
 
-    protected Optimized(Set<NodeInfo> nodeInfos, List<T> replicas) {
+    protected Optimized(List<NodeInfo> nodeInfos, List<T> replicas) {
       this.nodeInfos = nodeInfos;
       this.all = replicas;
       this.byBrokerTopic =
@@ -577,7 +556,7 @@ public interface ClusterInfo<T extends ReplicaInfo> {
     }
 
     @Override
-    public Set<NodeInfo> nodes() {
+    public List<NodeInfo> nodes() {
       return nodeInfos;
     }
 
diff --git a/common/src/main/java/org/astraea/common/admin/ClusterInfoBuilder.java b/common/src/main/java/org/astraea/common/admin/ClusterInfoBuilder.java
index 2fe5ddd6..f439619c 100644
--- a/common/src/main/java/org/astraea/common/admin/ClusterInfoBuilder.java
+++ b/common/src/main/java/org/astraea/common/admin/ClusterInfoBuilder.java
@@ -36,7 +36,7 @@ public class ClusterInfoBuilder {
 
   private final ClusterInfo<Replica> sourceCluster;
   private final List<
-          BiFunction<Set<NodeInfo>, List<Replica>, Map.Entry<Set<NodeInfo>, List<Replica>>>>
+          BiFunction<List<NodeInfo>, List<Replica>, Map.Entry<List<NodeInfo>, List<Replica>>>>
       alterations;
 
   private ClusterInfoBuilder(ClusterInfo<Replica> source) {
@@ -59,7 +59,7 @@ public class ClusterInfoBuilder {
    * @return this.
    */
   public ClusterInfoBuilder applyNodes(
-      BiFunction<Set<NodeInfo>, List<Replica>, Set<NodeInfo>> alteration) {
+      BiFunction<List<NodeInfo>, List<Replica>, List<NodeInfo>> alteration) {
     this.alterations.add(
         (nodes, replicas) -> Map.entry(alteration.apply(nodes, replicas), replicas));
     return this;
@@ -72,7 +72,7 @@ public class ClusterInfoBuilder {
    * @return this.
    */
   public ClusterInfoBuilder applyReplicas(
-      BiFunction<Set<NodeInfo>, List<Replica>, List<Replica>> alteration) {
+      BiFunction<List<NodeInfo>, List<Replica>, List<Replica>> alteration) {
     this.alterations.add((nodes, replicas) -> Map.entry(nodes, alteration.apply(nodes, replicas)));
     return this;
   }
@@ -85,9 +85,19 @@ public class ClusterInfoBuilder {
    */
   public ClusterInfoBuilder addNode(Set<Integer> brokerIds) {
     return applyNodes(
-        (nodes, replicas) ->
-            Stream.concat(nodes.stream(), brokerIds.stream().map(ClusterInfoBuilder::fakeNode))
-                .collect(Collectors.toUnmodifiableSet()));
+        (nodes, replicas) -> {
+          brokerIds.stream()
+              .filter(newId -> nodes.stream().anyMatch(node -> newId == node.id()))
+              .forEach(
+                  conflict -> {
+                    throw new IllegalStateException(
+                        "Attempt to add broker "
+                            + conflict
+                            + " but another broker with this id already existed");
+                  });
+          return Stream.concat(nodes.stream(), brokerIds.stream().map(ClusterInfoBuilder::fakeNode))
+              .collect(Collectors.toUnmodifiableList());
+        });
   }
 
   /**
@@ -116,7 +126,7 @@ public class ClusterInfoBuilder {
                               .collect(Collectors.toUnmodifiableList()));
                     else return node;
                   })
-              .collect(Collectors.toUnmodifiableSet());
+              .collect(Collectors.toUnmodifiableList());
         });
   }
 
diff --git a/common/src/main/java/org/astraea/common/admin/NodeInfo.java b/common/src/main/java/org/astraea/common/admin/NodeInfo.java
index 82510f0d..fffd74cc 100644
--- a/common/src/main/java/org/astraea/common/admin/NodeInfo.java
+++ b/common/src/main/java/org/astraea/common/admin/NodeInfo.java
@@ -18,7 +18,7 @@ package org.astraea.common.admin;
 
 import java.util.Objects;
 
-public interface NodeInfo extends Comparable<NodeInfo> {
+public interface NodeInfo {
 
   static NodeInfo of(org.apache.kafka.common.Node node) {
     return of(node.id(), node.host(), node.port());
@@ -86,13 +86,4 @@ public interface NodeInfo extends Comparable<NodeInfo> {
   default boolean offline() {
     return host() == null || host().isEmpty() || port() < 0;
   }
-
-  @Override
-  default int compareTo(NodeInfo other) {
-    var r = Integer.compare(id(), other.id());
-    if (r != 0) return r;
-    r = host().compareTo(other.host());
-    if (r != 0) return r;
-    return Integer.compare(port(), other.port());
-  }
 }
diff --git a/gui/src/main/java/org/astraea/gui/Context.java b/gui/src/main/java/org/astraea/gui/Context.java
index fa83e5db..d9464883 100644
--- a/gui/src/main/java/org/astraea/gui/Context.java
+++ b/gui/src/main/java/org/astraea/gui/Context.java
@@ -16,9 +16,9 @@
  */
 package org.astraea.gui;
 
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
@@ -35,7 +35,7 @@ public class Context {
 
   private Stage stage;
   private volatile int jmxPort = -1;
-  private final Map<NodeInfo, MBeanClient> clients = new ConcurrentHashMap<>();
+  private final Map<Integer, MBeanClient> clients = new ConcurrentHashMap<>();
 
   public Context() {}
 
@@ -60,16 +60,17 @@ public class Context {
     connectorClientReference.getAndSet(connectorClient);
   }
 
-  public void replace(Set<NodeInfo> nodes, int jmxPort) {
+  public void replace(List<NodeInfo> nodes, int jmxPort) {
     var copy = Map.copyOf(this.clients);
     this.jmxPort = jmxPort;
     this.clients.clear();
     this.clients.putAll(
-        nodes.stream().collect(Collectors.toMap(n -> n, n -> MBeanClient.jndi(n.host(), jmxPort))));
+        nodes.stream()
+            .collect(Collectors.toMap(NodeInfo::id, n -> MBeanClient.jndi(n.host(), jmxPort))));
     copy.values().forEach(MBeanClient::close);
   }
 
-  public Map<NodeInfo, MBeanClient> clients(Set<NodeInfo> nodeInfos) {
+  public Map<Integer, MBeanClient> clients(List<NodeInfo> nodeInfos) {
     if (jmxPort < 0) throw new IllegalArgumentException("Please define jmxPort");
     clients.keySet().stream()
         .filter(n -> !nodeInfos.contains(n))
@@ -83,7 +84,7 @@ public class Context {
         .filter(n -> !clients.containsKey(n))
         .forEach(
             n -> {
-              var previous = clients.put(n, MBeanClient.jndi(n.host(), jmxPort));
+              var previous = clients.put(n.id(), MBeanClient.jndi(n.host(), jmxPort));
               if (previous != null) previous.close();
             });
     return Map.copyOf(clients);
@@ -101,7 +102,7 @@ public class Context {
     return connectorClient;
   }
 
-  public Map<NodeInfo, MBeanClient> clients() {
+  public Map<Integer, MBeanClient> clients() {
     var copy = Map.copyOf(clients);
     if (copy.isEmpty()) throw new IllegalArgumentException("Please define jmx port");
     return copy;
diff --git a/gui/src/main/java/org/astraea/gui/tab/BrokerNode.java b/gui/src/main/java/org/astraea/gui/tab/BrokerNode.java
index 756821a3..368510a6 100644
--- a/gui/src/main/java/org/astraea/gui/tab/BrokerNode.java
+++ b/gui/src/main/java/org/astraea/gui/tab/BrokerNode.java
@@ -229,7 +229,7 @@ public class BrokerNode {
                                 .map(
                                     entry -> {
                                       var result = new LinkedHashMap<String, Object>();
-                                      result.put(BROKER_ID_KEY, entry.getKey().id());
+                                      result.put(BROKER_ID_KEY, entry.getKey());
                                       entry.getValue().stream()
                                           .flatMap(e -> e.getValue().entrySet().stream())
                                           .sorted(
@@ -376,7 +376,7 @@ public class BrokerNode {
         (id, path) ->
             context.hasMetrics()
                 ? context.clients().entrySet().stream()
-                    .filter(e -> e.getKey().id() == id)
+                    .filter(e -> e.getKey() == id)
                     .findFirst()
                     .map(Map.Entry::getValue)
                     .map(
diff --git a/gui/src/main/java/org/astraea/gui/tab/topic/TopicNode.java b/gui/src/main/java/org/astraea/gui/tab/topic/TopicNode.java
index ac0f5d07..b2930ad7 100644
--- a/gui/src/main/java/org/astraea/gui/tab/topic/TopicNode.java
+++ b/gui/src/main/java/org/astraea/gui/tab/topic/TopicNode.java
@@ -96,8 +96,8 @@ public class TopicNode {
                                 var map = new LinkedHashMap<String, Object>();
                                 map.put(TOPIC_NAME_KEY, topic);
                                 nodeMeters.forEach(
-                                    (nodeInfo, meters) -> {
-                                      var key = String.valueOf(nodeInfo.id());
+                                    (nodeId, meters) -> {
+                                      var key = String.valueOf(nodeId);
                                       var value =
                                           meters.stream()
                                               .filter(m -> m.topic().equals(topic))


Test patch:
diff --git a/app/src/test/java/org/astraea/app/web/BalancerHandlerTest.java b/app/src/test/java/org/astraea/app/web/BalancerHandlerTest.java
index e4b9d99a..c638ee65 100644
--- a/app/src/test/java/org/astraea/app/web/BalancerHandlerTest.java
+++ b/app/src/test/java/org/astraea/app/web/BalancerHandlerTest.java
@@ -240,7 +240,7 @@ public class BalancerHandlerTest extends RequireBrokerCluster {
     try (var admin = Admin.of(bootstrapServers())) {
       var currentClusterInfo =
           ClusterInfo.of(
-              Set.of(NodeInfo.of(10, "host", 22), NodeInfo.of(11, "host", 22)),
+              List.of(NodeInfo.of(10, "host", 22), NodeInfo.of(11, "host", 22)),
               List.of(
                   Replica.builder()
                       .topic("topic")
@@ -256,22 +256,6 @@ public class BalancerHandlerTest extends RequireBrokerCluster {
                       .path("/tmp/aa")
                       .build()));
 
-      var clusterLogAllocation =
-          ClusterInfo.of(
-              List.of(
-                  Replica.builder()
-                      .topic("topic")
-                      .partition(0)
-                      .nodeInfo(NodeInfo.of(11, "host", 22))
-                      .lag(0)
-                      .size(100)
-                      .isLeader(true)
-                      .inSync(true)
-                      .isFuture(false)
-                      .isOffline(false)
-                      .isPreferredLeader(true)
-                      .path("/tmp/aa")
-                      .build()));
       HasClusterCost clusterCostFunction =
           (clusterInfo, clusterBean) -> () -> clusterInfo == currentClusterInfo ? 100D : 10D;
       HasMoveCost moveCostFunction =
diff --git a/common/src/test/java/org/astraea/common/admin/AdminTest.java b/common/src/test/java/org/astraea/common/admin/AdminTest.java
index 7b69c022..4c4d5847 100644
--- a/common/src/test/java/org/astraea/common/admin/AdminTest.java
+++ b/common/src/test/java/org/astraea/common/admin/AdminTest.java
@@ -306,7 +306,7 @@ public class AdminTest extends RequireBrokerCluster {
       Assertions.assertInstanceOf(
           SortedSet.class, admin.topicPartitionReplicas(brokerIds()).toCompletableFuture().join());
 
-      Assertions.assertInstanceOf(SortedSet.class, admin.nodeInfos().toCompletableFuture().join());
+      Assertions.assertInstanceOf(List.class, admin.nodeInfos().toCompletableFuture().join());
 
       var brokers = admin.brokers().toCompletableFuture().join();
       Assertions.assertEquals(
diff --git a/common/src/test/java/org/astraea/common/admin/ClusterInfoBuilderTest.java b/common/src/test/java/org/astraea/common/admin/ClusterInfoBuilderTest.java
index defd7938..8fb8c38d 100644
--- a/common/src/test/java/org/astraea/common/admin/ClusterInfoBuilderTest.java
+++ b/common/src/test/java/org/astraea/common/admin/ClusterInfoBuilderTest.java
@@ -43,20 +43,20 @@ class ClusterInfoBuilderTest {
             .isPreferredLeader(true)
             .isLeader(true)
             .build();
-    var cluster = ClusterInfo.of(Set.of(host1000, host2000, host3000), List.of(replica));
+    var cluster = ClusterInfo.of(List.of(host1000, host2000, host3000), List.of(replica));
 
     Assertions.assertEquals(
-        Set.of(host1000, host2000, host3000), ClusterInfoBuilder.builder(cluster).build().nodes());
+        List.of(host1000, host2000, host3000), ClusterInfoBuilder.builder(cluster).build().nodes());
     Assertions.assertEquals(
         List.of(replica), ClusterInfoBuilder.builder(cluster).build().replicas());
-    Assertions.assertEquals(Set.of(), ClusterInfoBuilder.builder().build().nodes());
+    Assertions.assertEquals(List.of(), ClusterInfoBuilder.builder().build().nodes());
     Assertions.assertEquals(List.of(), ClusterInfoBuilder.builder().build().replicas());
   }
 
   @Test
   void addNode() {
     Assertions.assertEquals(
-        Set.of(), ClusterInfoBuilder.builder().addNode(Set.of()).build().nodes());
+        List.of(), ClusterInfoBuilder.builder().addNode(Set.of()).build().nodes());
     Assertions.assertEquals(
         Set.of(1, 2, 3, 4, 5, 100),
         ClusterInfoBuilder.builder().addNode(Set.of(1, 2, 3, 4, 5, 100)).build().nodes().stream()
diff --git a/common/src/test/java/org/astraea/common/admin/ClusterInfoTest.java b/common/src/test/java/org/astraea/common/admin/ClusterInfoTest.java
index 038779e6..bd127e72 100644
--- a/common/src/test/java/org/astraea/common/admin/ClusterInfoTest.java
+++ b/common/src/test/java/org/astraea/common/admin/ClusterInfoTest.java
@@ -18,7 +18,9 @@ package org.astraea.common.admin;
 
 import java.util.List;
 import java.util.NoSuchElementException;
+import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.apache.kafka.common.Cluster;
 import org.astraea.common.cost.ReplicaLeaderCost;
 import org.junit.jupiter.api.Assertions;
@@ -27,6 +29,33 @@ import org.mockito.Mockito;
 
 public class ClusterInfoTest {
 
+  /**
+   * build a cluster info based on replicas. Noted that the node info are collected by the replicas.
+   *
+   * <p>Be aware that the <code>replicas</code> parameter describes <strong>the replica lists of a
+   * subset of topic/partitions</strong>. It doesn't require the topic/partition part to have
+   * cluster-wide complete information. But the replica list has to be complete. Provide a partial
+   * replica list might result in data loss or unintended replica drop during rebalance plan
+   * proposing & execution.
+   *
+   * @param replicas used to build cluster info
+   * @return cluster info
+   * @param <T> ReplicaInfo or Replica
+   */
+  public static <T extends ReplicaInfo> ClusterInfo<T> of(List<T> replicas) {
+    // TODO: this method is not suitable for production use. Move it to the test scope.
+    //  see https://github.com/skiptests/astraea/issues/1185
+    return ClusterInfo.of(
+        replicas.stream()
+            .map(ReplicaInfo::nodeInfo)
+            .collect(Collectors.groupingBy(NodeInfo::id, Collectors.reducing((x, y) -> x)))
+            .values()
+            .stream()
+            .flatMap(Optional::stream)
+            .collect(Collectors.toUnmodifiableList()),
+        replicas);
+  }
+
   @Test
   void testDiff() {
     /*
@@ -169,7 +198,7 @@ public class ClusterInfoTest {
                 .isPreferredLeader(false)
                 .path("/data-folder-01")
                 .build());
-    var nodeInfos = Set.of(NodeInfo.of(0, "", -1), NodeInfo.of(1, "", -1), NodeInfo.of(2, "", -1));
+    var nodeInfos = List.of(NodeInfo.of(0, "", -1), NodeInfo.of(1, "", -1), NodeInfo.of(2, "", -1));
     var before = ClusterInfo.of(nodeInfos, beforeReplicas);
     var after = ClusterInfo.of(nodeInfos, afterReplicas);
     var changes = ClusterInfo.diff(before, after);
@@ -247,7 +276,7 @@ public class ClusterInfoTest {
                 .path("/data-folder-01")
                 .build());
 
-    var clusterInfo = ClusterInfo.of(replicas);
+    var clusterInfo = ClusterInfoTest.of(replicas);
     var maskedClusterInfoHasReplicas = ClusterInfo.masked(clusterInfo, t -> t.equals("test-1"));
     var maskedClusterInfoNoReplicas =
         ClusterInfo.masked(clusterInfo, t -> t.equals("No topic name the same."));
diff --git a/common/src/test/java/org/astraea/common/admin/ReplicaInfoTest.java b/common/src/test/java/org/astraea/common/admin/ReplicaInfoTest.java
index 8fac7690..7867b4d8 100644
--- a/common/src/test/java/org/astraea/common/admin/ReplicaInfoTest.java
+++ b/common/src/test/java/org/astraea/common/admin/ReplicaInfoTest.java
@@ -18,7 +18,6 @@ package org.astraea.common.admin;
 
 import java.util.Arrays;
 import java.util.List;
-import java.util.Set;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
 import org.apache.kafka.common.Node;
@@ -41,12 +40,12 @@ public class ReplicaInfoTest {
     var kafkaPartition = partitionInfo();
     var replicaInfo = ReplicaInfo.of(kafkaPartition);
     var followerReplicaNode =
-        (Supplier<Set<NodeInfo>>)
+        (Supplier<List<NodeInfo>>)
             () ->
                 Arrays.stream(kafkaPartition.replicas())
                     .map(NodeInfo::of)
                     .filter(node -> !node.equals(NodeInfo.of(kafkaPartition.leader())))
-                    .collect(Collectors.toUnmodifiableSet());
+                    .collect(Collectors.toUnmodifiableList());
     final List<ReplicaInfo> leader =
         replicaInfo.stream().filter(ReplicaInfo::isLeader).collect(Collectors.toList());
     var followers =
@@ -70,7 +69,7 @@ public class ReplicaInfoTest {
         "The follower replica node count should match");
     Assertions.assertEquals(
         followerReplicaNode.get(),
-        followers.stream().map(ReplicaInfo::nodeInfo).collect(Collectors.toUnmodifiableSet()),
+        followers.stream().map(ReplicaInfo::nodeInfo).collect(Collectors.toUnmodifiableList()),
         "The follower replica node set should match");
   }
 }
diff --git a/common/src/test/java/org/astraea/common/balancer/FakeClusterInfo.java b/common/src/test/java/org/astraea/common/balancer/FakeClusterInfo.java
index cf00f107..dcc3fbda 100644
--- a/common/src/test/java/org/astraea/common/balancer/FakeClusterInfo.java
+++ b/common/src/test/java/org/astraea/common/balancer/FakeClusterInfo.java
@@ -170,6 +170,6 @@ public class FakeClusterInfo {
                                     .build()))
             .collect(Collectors.toUnmodifiableList());
 
-    return ClusterInfo.of(Set.copyOf(nodes), replicas);
+    return ClusterInfo.of(List.copyOf(nodes), replicas);
   }
 }
diff --git a/common/src/test/java/org/astraea/common/balancer/executor/StraightPlanExecutorTest.java b/common/src/test/java/org/astraea/common/balancer/executor/StraightPlanExecutorTest.java
index cf9466d1..1892a3a7 100644
--- a/common/src/test/java/org/astraea/common/balancer/executor/StraightPlanExecutorTest.java
+++ b/common/src/test/java/org/astraea/common/balancer/executor/StraightPlanExecutorTest.java
@@ -26,6 +26,7 @@ import java.util.stream.IntStream;
 import org.astraea.common.Utils;
 import org.astraea.common.admin.Admin;
 import org.astraea.common.admin.ClusterInfo;
+import org.astraea.common.admin.ClusterInfoTest;
 import org.astraea.common.admin.NodeInfo;
 import org.astraea.common.admin.Replica;
 import org.astraea.common.admin.TopicPartition;
@@ -97,7 +98,7 @@ class StraightPlanExecutorTest extends RequireBrokerCluster {
               .stream()
               .flatMap(Collection::stream)
               .collect(Collectors.toUnmodifiableList());
-      final var expectedAllocation = ClusterInfo.of(allocation);
+      final var expectedAllocation = ClusterInfoTest.of(allocation);
       final var expectedTopicPartition = expectedAllocation.topicPartitions();
 
       var execute =
diff --git a/common/src/test/java/org/astraea/common/balancer/tweakers/ShuffleTweakerTest.java b/common/src/test/java/org/astraea/common/balancer/tweakers/ShuffleTweakerTest.java
index 3f1da6b8..06fac68c 100644
--- a/common/src/test/java/org/astraea/common/balancer/tweakers/ShuffleTweakerTest.java
+++ b/common/src/test/java/org/astraea/common/balancer/tweakers/ShuffleTweakerTest.java
@@ -25,6 +25,7 @@ import java.util.concurrent.atomic.LongAdder;
 import java.util.stream.Collectors;
 import org.astraea.common.Utils;
 import org.astraea.common.admin.ClusterInfo;
+import org.astraea.common.admin.ClusterInfoTest;
 import org.astraea.common.admin.NodeInfo;
 import org.astraea.common.admin.Replica;
 import org.astraea.common.admin.TopicPartition;
@@ -201,7 +202,7 @@ class ShuffleTweakerTest {
             .path("/a")
             .build();
     var allocation =
-        ClusterInfo.of(
+        ClusterInfoTest.of(
             List.of(
                 Replica.builder(base)
                     .topic("normal-topic")
diff --git a/common/src/test/java/org/astraea/common/cost/ReplicaLeaderCostTest.java b/common/src/test/java/org/astraea/common/cost/ReplicaLeaderCostTest.java
index 37e5f770..eda6d02f 100644
--- a/common/src/test/java/org/astraea/common/cost/ReplicaLeaderCostTest.java
+++ b/common/src/test/java/org/astraea/common/cost/ReplicaLeaderCostTest.java
@@ -18,10 +18,10 @@ package org.astraea.common.cost;
 
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.stream.Collectors;
 import org.astraea.common.admin.ClusterBean;
 import org.astraea.common.admin.ClusterInfo;
+import org.astraea.common.admin.ClusterInfoTest;
 import org.astraea.common.admin.NodeInfo;
 import org.astraea.common.admin.Replica;
 import org.astraea.common.admin.ReplicaInfo;
@@ -44,7 +44,7 @@ public class ReplicaLeaderCostTest {
             ReplicaInfo.of("topic", 0, NodeInfo.of(11, "broker1", 1111), true, true, false));
     var clusterInfo =
         ClusterInfo.of(
-            Set.of(
+            List.of(
                 NodeInfo.of(10, "host1", 8080),
                 NodeInfo.of(11, "host1", 8080),
                 NodeInfo.of(12, "host1", 8080)),
@@ -193,8 +193,8 @@ public class ReplicaLeaderCostTest {
                 .isPreferredLeader(false)
                 .path("")
                 .build());
-    var beforeClusterInfo = ClusterInfo.of(before);
-    var afterClusterInfo = ClusterInfo.of(after);
+    var beforeClusterInfo = ClusterInfoTest.of(before);
+    var afterClusterInfo = ClusterInfoTest.of(after);
     var moveCost = costFunction.moveCost(beforeClusterInfo, afterClusterInfo, ClusterBean.EMPTY);
     Assertions.assertEquals(1, moveCost.totalCost());
     Assertions.assertEquals(2, moveCost.changes().size());
diff --git a/common/src/test/java/org/astraea/common/cost/ReplicaNumberCostTest.java b/common/src/test/java/org/astraea/common/cost/ReplicaNumberCostTest.java
index 48721dab..7182e79f 100644
--- a/common/src/test/java/org/astraea/common/cost/ReplicaNumberCostTest.java
+++ b/common/src/test/java/org/astraea/common/cost/ReplicaNumberCostTest.java
@@ -18,11 +18,11 @@ package org.astraea.common.cost;
 
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import org.astraea.common.admin.ClusterBean;
 import org.astraea.common.admin.ClusterInfo;
+import org.astraea.common.admin.ClusterInfoTest;
 import org.astraea.common.admin.NodeInfo;
 import org.astraea.common.admin.Replica;
 import org.junit.jupiter.api.Assertions;
@@ -35,7 +35,7 @@ class ReplicaNumberCostTest {
     var nodeInfo = NodeInfo.of(10, "h", 100);
     var replica = Replica.builder().nodeInfo(nodeInfo).topic("t").build();
     var clusterInfo =
-        ClusterInfo.of(Set.of(nodeInfo, NodeInfo.of(100, "h", 100)), List.of(replica));
+        ClusterInfo.of(List.of(nodeInfo, NodeInfo.of(100, "h", 100)), List.of(replica));
     var cost = new ReplicaNumberCost();
     Assertions.assertEquals(
         Long.MAX_VALUE, cost.clusterCost(clusterInfo, ClusterBean.EMPTY).value());
@@ -44,7 +44,7 @@ class ReplicaNumberCostTest {
   @Test
   void testClusterCostForSingleNode() {
     var nodeInfo = NodeInfo.of(10, "h", 100);
-    var clusterInfo = ClusterInfo.of(Set.of(nodeInfo), List.<Replica>of());
+    var clusterInfo = ClusterInfo.of(List.of(nodeInfo), List.<Replica>of());
     var cost = new ReplicaNumberCost();
     Assertions.assertEquals(0, cost.clusterCost(clusterInfo, ClusterBean.EMPTY).value());
   }
@@ -53,7 +53,8 @@ class ReplicaNumberCostTest {
   void testClusterCostForAllInSingleNode() {
     var nodeInfo = NodeInfo.of(10, "h", 100);
     var replica = Replica.builder().nodeInfo(nodeInfo).topic("t").build();
-    var clusterInfo = ClusterInfo.of(Set.of(nodeInfo, NodeInfo.of(11, "j", 100)), List.of(replica));
+    var clusterInfo =
+        ClusterInfo.of(List.of(nodeInfo, NodeInfo.of(11, "j", 100)), List.of(replica));
     var cost = new ReplicaNumberCost();
     Assertions.assertEquals(
         Long.MAX_VALUE, cost.clusterCost(clusterInfo, ClusterBean.EMPTY).value());
@@ -67,7 +68,7 @@ class ReplicaNumberCostTest {
             .mapToObj(i -> Replica.builder().nodeInfo(nodeInfo).topic("t").build())
             .collect(Collectors.toCollection(ArrayList::new));
     replicas.add(Replica.builder().nodeInfo(NodeInfo.of(11, "j", 100)).topic("t").build());
-    var clusterInfo = ClusterInfo.of(Set.of(nodeInfo, NodeInfo.of(11, "j", 100)), replicas);
+    var clusterInfo = ClusterInfo.of(List.of(nodeInfo, NodeInfo.of(11, "j", 100)), replicas);
     var cost = new ReplicaNumberCost();
     Assertions.assertEquals(9, cost.clusterCost(clusterInfo, ClusterBean.EMPTY).value());
   }
@@ -183,8 +184,8 @@ class ReplicaNumberCostTest {
                 .isPreferredLeader(false)
                 .path("")
                 .build());
-    var beforeClusterInfo = ClusterInfo.of(before);
-    var afterClusterInfo = ClusterInfo.of(after);
+    var beforeClusterInfo = ClusterInfoTest.of(before);
+    var afterClusterInfo = ClusterInfoTest.of(after);
     var movecost = costFunction.moveCost(beforeClusterInfo, afterClusterInfo, ClusterBean.EMPTY);
     Assertions.assertEquals(2, movecost.totalCost());
     Assertions.assertEquals(3, movecost.changes().size());
diff --git a/common/src/test/java/org/astraea/common/cost/ReplicaSizeCostTest.java b/common/src/test/java/org/astraea/common/cost/ReplicaSizeCostTest.java
index 8b65e0cf..6e7a13c1 100644
--- a/common/src/test/java/org/astraea/common/cost/ReplicaSizeCostTest.java
+++ b/common/src/test/java/org/astraea/common/cost/ReplicaSizeCostTest.java
@@ -18,7 +18,6 @@ package org.astraea.common.cost;
 
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import org.astraea.common.admin.ClusterBean;
 import org.astraea.common.admin.ClusterInfo;
 import org.astraea.common.admin.NodeInfo;
@@ -72,7 +71,7 @@ class ReplicaSizeCostTest {
 
   static ClusterInfo<Replica> getClusterInfo(List<Replica> replicas) {
     return ClusterInfo.of(
-        Set.of(NodeInfo.of(1, "", -1), NodeInfo.of(2, "", -1), NodeInfo.of(3, "", -1)),
+        List.of(NodeInfo.of(1, "", -1), NodeInfo.of(2, "", -1), NodeInfo.of(3, "", -1)),
         List.of(
             replicas.get(0),
             replicas.get(1),
diff --git a/common/src/test/java/org/astraea/common/partitioner/StrictCostDispatcherTest.java b/common/src/test/java/org/astraea/common/partitioner/StrictCostDispatcherTest.java
index bcaffc27..c800aef3 100644
--- a/common/src/test/java/org/astraea/common/partitioner/StrictCostDispatcherTest.java
+++ b/common/src/test/java/org/astraea/common/partitioner/StrictCostDispatcherTest.java
@@ -27,6 +27,7 @@ import org.astraea.common.Configuration;
 import org.astraea.common.Utils;
 import org.astraea.common.admin.ClusterBean;
 import org.astraea.common.admin.ClusterInfo;
+import org.astraea.common.admin.ClusterInfoTest;
 import org.astraea.common.admin.NodeInfo;
 import org.astraea.common.admin.ReplicaInfo;
 import org.astraea.common.cost.BrokerCost;
@@ -109,7 +110,7 @@ public class StrictCostDispatcherTest {
       Assertions.assertEquals(
           10,
           dispatcher.partition(
-              "topic", new byte[0], new byte[0], ClusterInfo.of(List.of(replicaInfo))));
+              "topic", new byte[0], new byte[0], ClusterInfoTest.of(List.of(replicaInfo))));
     }
   }
 
@@ -161,7 +162,10 @@ public class StrictCostDispatcherTest {
       dispatcher.configure(
           Map.of(costFunction, 1D), Optional.empty(), Map.of(), Duration.ofSeconds(10));
       dispatcher.partition(
-          "topic", new byte[0], new byte[0], ClusterInfo.of(List.of(replicaInfo0, replicaInfo1)));
+          "topic",
+          new byte[0],
+          new byte[0],
+          ClusterInfoTest.of(List.of(replicaInfo0, replicaInfo1)));
       Assertions.assertEquals(0, dispatcher.metricCollector.listFetchers().size());
     }
   }
@@ -214,7 +218,7 @@ public class StrictCostDispatcherTest {
               "topic",
               new byte[0],
               new byte[0],
-              ClusterInfo.of(List.of(replicaInfo0, replicaInfo1))));
+              ClusterInfoTest.of(List.of(replicaInfo0, replicaInfo1))));
     }
   }
 
@@ -269,7 +273,7 @@ public class StrictCostDispatcherTest {
         try (var dispatcher = new StrictCostDispatcher()) {
           var nodeInfo = NodeInfo.of(10, "host", 2222);
           var clusterInfo =
-              ClusterInfo.of(List.of(ReplicaInfo.of("topic", 0, nodeInfo, true, true, false)));
+              ClusterInfoTest.of(List.of(ReplicaInfo.of("topic", 0, nodeInfo, true, true, false)));
 
           Assertions.assertEquals(0, dispatcher.metricCollector.listIdentities().size());
           dispatcher.costFunction =
diff --git a/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinDispatchTest.java b/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinDispatchTest.java
index e3148470..e0ca0c71 100644
--- a/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinDispatchTest.java
+++ b/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinDispatchTest.java
@@ -272,7 +272,7 @@ public class SmoothWeightRoundRobinDispatchTest extends RequireBrokerCluster {
     var node3 = Mockito.mock(NodeInfo.class);
     Mockito.when(node3.id()).thenReturn(3);
     var re3 = ReplicaInfo.of(topic, 2, node3, true, true, false);
-    var testCluster = ClusterInfo.of(List.of(re1, re2, re3));
+    var testCluster = ClusterInfo.of(List.of(node1, node2, node3), List.of(re1, re2, re3));
     Assertions.assertEquals(1, smoothWeight.getAndChoose(topic, testCluster));
     Assertions.assertEquals(2, smoothWeight.getAndChoose(topic, testCluster));
     Assertions.assertEquals(3, smoothWeight.getAndChoose(topic, testCluster));
diff --git a/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinTest.java b/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinTest.java
index 68cdbf19..d51629ae 100644
--- a/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinTest.java
+++ b/common/src/test/java/org/astraea/common/partitioner/smooth/SmoothWeightRoundRobinTest.java
@@ -56,10 +56,16 @@ public class SmoothWeightRoundRobinTest {
   }
 
   ClusterInfo<ReplicaInfo> clusterInfo() {
+    var nodes =
+        List.of(
+            NodeInfo.of(1, "host", 1111),
+            NodeInfo.of(2, "host", 1111),
+            NodeInfo.of(3, "host", 1111));
     return ClusterInfo.of(
+        nodes,
         List.of(
-            ReplicaInfo.of("test", 1, NodeInfo.of(1, "host", 1111), true, true, false),
-            ReplicaInfo.of("test", 2, NodeInfo.of(2, "host", 1111), true, true, false),
-            ReplicaInfo.of("test", 3, NodeInfo.of(3, "host", 1111), true, true, false)));
+            ReplicaInfo.of("test", 1, nodes.get(0), true, true, false),
+            ReplicaInfo.of("test", 2, nodes.get(1), true, true, false),
+            ReplicaInfo.of("test", 3, nodes.get(2), true, true, false)));
   }
 }
diff --git a/gui/src/test/java/org/astraea/gui/tab/BalancerNodeTest.java b/gui/src/test/java/org/astraea/gui/tab/BalancerNodeTest.java
index a76af044..d4bb7d3e 100644
--- a/gui/src/test/java/org/astraea/gui/tab/BalancerNodeTest.java
+++ b/gui/src/test/java/org/astraea/gui/tab/BalancerNodeTest.java
@@ -116,6 +116,8 @@ class BalancerNodeTest extends RequireBrokerCluster {
   void testResult() {
     var topic = Utils.randomString();
     var leaderSize = 100;
+    var allNodes =
+        List.of(NodeInfo.of(0, "aa", 0), NodeInfo.of(1, "aa", 0), NodeInfo.of(3, "aa", 0));
     var beforeReplicas =
         List.of(
             Replica.builder()
@@ -123,7 +125,7 @@ class BalancerNodeTest extends RequireBrokerCluster {
                 .isPreferredLeader(false)
                 .topic(topic)
                 .partition(0)
-                .nodeInfo(NodeInfo.of(0, "aa", 0))
+                .nodeInfo(allNodes.get(0))
                 .size(leaderSize)
                 .path("/tmp/aaa")
                 .build(),
@@ -132,7 +134,7 @@ class BalancerNodeTest extends RequireBrokerCluster {
                 .isPreferredLeader(true)
                 .topic(topic)
                 .partition(0)
-                .nodeInfo(NodeInfo.of(1, "aa", 0))
+                .nodeInfo(allNodes.get(1))
                 .size(leaderSize)
                 .path("/tmp/bbb")
                 .build());
@@ -143,7 +145,7 @@ class BalancerNodeTest extends RequireBrokerCluster {
                 .isPreferredLeader(false)
                 .topic(topic)
                 .partition(0)
-                .nodeInfo(NodeInfo.of(3, "aa", 0))
+                .nodeInfo(allNodes.get(2))
                 .size(leaderSize)
                 .path("/tmp/ddd")
                 .build(),
@@ -152,17 +154,17 @@ class BalancerNodeTest extends RequireBrokerCluster {
                 .isPreferredLeader(true)
                 .topic(topic)
                 .partition(0)
-                .nodeInfo(NodeInfo.of(1, "aa", 0))
+                .nodeInfo(allNodes.get(1))
                 .size(leaderSize)
                 .path("/tmp/bbb")
                 .build());
-    var beforeClusterInfo = ClusterInfo.of(Set.of(), beforeReplicas);
+    var beforeClusterInfo = ClusterInfo.of(List.of(), beforeReplicas);
 
     var results =
         BalancerNode.assignmentResult(
             beforeClusterInfo,
             new Balancer.Plan(
-                ClusterInfo.of(afterReplicas),
+                ClusterInfo.of(allNodes, afterReplicas),
                 new ReplicaLeaderCost().clusterCost(beforeClusterInfo, ClusterBean.EMPTY),
                 new ReplicaLeaderCost().clusterCost(beforeClusterInfo, ClusterBean.EMPTY),
                 List.of(MoveCost.builder().build())));
diff --git a/gui/src/test/java/org/astraea/gui/tab/topic/ReplicaNodeTest.java b/gui/src/test/java/org/astraea/gui/tab/topic/ReplicaNodeTest.java
index cd4a262f..b74cb8df 100644
--- a/gui/src/test/java/org/astraea/gui/tab/topic/ReplicaNodeTest.java
+++ b/gui/src/test/java/org/astraea/gui/tab/topic/ReplicaNodeTest.java
@@ -135,13 +135,14 @@ public class ReplicaNodeTest extends RequireBrokerCluster {
     var topic = Utils.randomString();
     var partition = 0;
     var leaderSize = 100;
+    var nodes = List.of(NodeInfo.of(0, "aa", 0), NodeInfo.of(1, "aa", 0), NodeInfo.of(2, "aa", 0));
     var replicas =
         List.of(
             Replica.builder()
                 .isLeader(true)
                 .topic(topic)
                 .partition(partition)
-                .nodeInfo(NodeInfo.of(0, "aa", 0))
+                .nodeInfo(nodes.get(0))
                 .size(leaderSize)
                 .path("/tmp/aaa")
                 .build(),
@@ -149,17 +150,17 @@ public class ReplicaNodeTest extends RequireBrokerCluster {
                 .isLeader(false)
                 .topic(topic)
                 .partition(partition)
-                .nodeInfo(NodeInfo.of(1, "aa", 0))
+                .nodeInfo(nodes.get(1))
                 .size(20)
                 .build(),
             Replica.builder()
                 .isLeader(false)
                 .topic(topic)
                 .partition(partition)
-                .nodeInfo(NodeInfo.of(2, "aa", 0))
+                .nodeInfo(nodes.get(2))
                 .size(30)
                 .build());
-    var results = ReplicaNode.allResult(ClusterInfo.of(replicas));
+    var results = ReplicaNode.allResult(ClusterInfo.of(nodes, replicas));
     Assertions.assertEquals(3, results.size());
     Assertions.assertEquals(
         1,