xiangfu0 commented on code in PR #19916:
URL: https://github.com/apache/pinot/pull/19916#discussion_r4332399450


##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/rebalance/TableRebalancer.java:
##########
@@ -1622,174 +1656,280 @@ private static void handleErrorInstance(String 
segmentName, String instanceName,
     }
   }
 
+  /// Returns the target assignment for the ideal state changed while waiting 
for the external view to converge, when
+  /// the segments to be moved are unchanged: the current assignment with the 
target of the segments to be moved. For
+  /// a partition-pinned table (strict replica-group routing), a partition 
added while waiting keeps its current
+  /// instances, and a segment added while waiting, which is assigned to the 
current instances of its partition, is
+  /// moved to the target instances of its partition, unless it is a CONSUMING 
segment and CONSUMING segments are not
+  /// included in the rebalance. Returns `null` when the target assignment 
must be recalculated instead: when the
+  /// partition id of such a segment is not in its name, or when CONSUMING 
segments are not included and an ONLINE
+  /// segment was added to a pauseless table, because it can still be 
committing (which only its ZK metadata tells),
+  /// in which case it is not moved either.
+  @Nullable
+  @VisibleForTesting
+  static TableAssignment getTargetAssignmentForNewIdealState(Map<String, 
Map<String, String>> currentAssignment,
+      Map<String, Map<String, String>> targetAssignment, Map<Integer, 
List<String>> currentPartitionAssignment,
+      Map<Integer, List<String>> targetPartitionAssignment, List<String> 
segmentsToMove,
+      boolean enableStrictReplicaGroup, boolean includeConsuming, boolean 
pauseless, Logger tableRebalanceLogger) {
+    // Other instance assignment code returns a TreeMap to keep it sorted, 
doing the same here
+    Map<String, Map<String, String>> newTargetAssignment = new 
TreeMap<>(currentAssignment);
+    for (String segment : segmentsToMove) {
+      newTargetAssignment.put(segment, targetAssignment.get(segment));
+    }
+    if (!enableStrictReplicaGroup) {
+      return new TableAssignment(newTargetAssignment, 
targetPartitionAssignment);
+    }
+    Map<Integer, List<String>> newTargetPartitionAssignment = new 
TreeMap<>(targetPartitionAssignment);
+    for (Map.Entry<Integer, List<String>> entry : 
currentPartitionAssignment.entrySet()) {
+      newTargetPartitionAssignment.putIfAbsent(entry.getKey(), 
entry.getValue());
+    }
+    for (Map.Entry<String, Map<String, String>> entry : 
currentAssignment.entrySet()) {
+      String segment = entry.getKey();
+      if (targetAssignment.containsKey(segment)) {
+        continue;
+      }
+      Map<String, String> currentInstanceStateMap = entry.getValue();
+      String state;
+      if (currentInstanceStateMap.containsValue(SegmentStateModel.ONLINE)) {
+        if (!includeConsuming && pauseless) {
+          tableRebalanceLogger.info("Found new ONLINE segment: {} of pauseless 
table while CONSUMING segments are not "
+              + "included, re-calculating the target assignment based on the 
new IdealState", segment);
+          return null;
+        }
+        state = SegmentStateModel.ONLINE;
+      } else if 
(currentInstanceStateMap.containsValue(SegmentStateModel.CONSUMING)) {
+        if (!includeConsuming) {
+          continue;
+        }
+        state = SegmentStateModel.CONSUMING;
+      } else {
+        // All instances are OFFLINE, and OFFLINE segments are not rebalanced
+        continue;
+      }
+      Integer partitionId = 
SegmentUtils.getPartitionIdFromSegmentName(segment);
+      if (partitionId == null) {
+        tableRebalanceLogger.info("Found new segment: {} without partition id 
in its name, re-calculating the target "
+            + "assignment based on the new IdealState", segment);
+        return null;
+      }
+      List<String> currentInstances = 
currentPartitionAssignment.get(partitionId);
+      if (currentInstances == null || currentInstances.size() != 
currentInstanceStateMap.size()
+          || !currentInstanceStateMap.keySet().containsAll(currentInstances)) {
+        // Not assigned to the instances of its partition (e.g. relocated to 
the COMPLETED instances), keep it as is
+        continue;
+      }
+      newTargetAssignment.put(segment,

Review Comment:
   **A new ONLINE segment sitting on its partition entry is retargeted to the 
partition's CONSUMING-placement target even when the table relocates COMPLETED 
segments**, which disagrees with what `rebalanceTable` computes (COMPLETED 
strategy). Example: non-upsert strictReplicaGroup table with COMPLETED instance 
partitions, rebalanced with includeConsuming=false; during one EV wait 
partition P commits twice, so segment S created and committed within that wait 
is ONLINE on P's intermediate entry and absent from the target; this line sends 
it to P's consuming target, leaving a completed segment on consuming servers 
(extra moves, fixed only by a later relocation).



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/rebalance/TableRebalancer.java:
##########
@@ -1622,174 +1656,280 @@ private static void handleErrorInstance(String 
segmentName, String instanceName,
     }
   }
 
+  /// Returns the target assignment for the ideal state changed while waiting 
for the external view to converge, when
+  /// the segments to be moved are unchanged: the current assignment with the 
target of the segments to be moved. For
+  /// a partition-pinned table (strict replica-group routing), a partition 
added while waiting keeps its current
+  /// instances, and a segment added while waiting, which is assigned to the 
current instances of its partition, is
+  /// moved to the target instances of its partition, unless it is a CONSUMING 
segment and CONSUMING segments are not
+  /// included in the rebalance. Returns `null` when the target assignment 
must be recalculated instead: when the
+  /// partition id of such a segment is not in its name, or when CONSUMING 
segments are not included and an ONLINE
+  /// segment was added to a pauseless table, because it can still be 
committing (which only its ZK metadata tells),
+  /// in which case it is not moved either.
+  @Nullable
+  @VisibleForTesting
+  static TableAssignment getTargetAssignmentForNewIdealState(Map<String, 
Map<String, String>> currentAssignment,
+      Map<String, Map<String, String>> targetAssignment, Map<Integer, 
List<String>> currentPartitionAssignment,
+      Map<Integer, List<String>> targetPartitionAssignment, List<String> 
segmentsToMove,
+      boolean enableStrictReplicaGroup, boolean includeConsuming, boolean 
pauseless, Logger tableRebalanceLogger) {
+    // Other instance assignment code returns a TreeMap to keep it sorted, 
doing the same here
+    Map<String, Map<String, String>> newTargetAssignment = new 
TreeMap<>(currentAssignment);
+    for (String segment : segmentsToMove) {
+      newTargetAssignment.put(segment, targetAssignment.get(segment));
+    }
+    if (!enableStrictReplicaGroup) {
+      return new TableAssignment(newTargetAssignment, 
targetPartitionAssignment);
+    }
+    Map<Integer, List<String>> newTargetPartitionAssignment = new 
TreeMap<>(targetPartitionAssignment);
+    for (Map.Entry<Integer, List<String>> entry : 
currentPartitionAssignment.entrySet()) {
+      newTargetPartitionAssignment.putIfAbsent(entry.getKey(), 
entry.getValue());
+    }
+    for (Map.Entry<String, Map<String, String>> entry : 
currentAssignment.entrySet()) {
+      String segment = entry.getKey();
+      if (targetAssignment.containsKey(segment)) {
+        continue;
+      }
+      Map<String, String> currentInstanceStateMap = entry.getValue();
+      String state;
+      if (currentInstanceStateMap.containsValue(SegmentStateModel.ONLINE)) {
+        if (!includeConsuming && pauseless) {
+          tableRebalanceLogger.info("Found new ONLINE segment: {} of pauseless 
table while CONSUMING segments are not "
+              + "included, re-calculating the target assignment based on the 
new IdealState", segment);
+          return null;
+        }
+        state = SegmentStateModel.ONLINE;
+      } else if 
(currentInstanceStateMap.containsValue(SegmentStateModel.CONSUMING)) {
+        if (!includeConsuming) {
+          continue;
+        }
+        state = SegmentStateModel.CONSUMING;
+      } else {
+        // All instances are OFFLINE, and OFFLINE segments are not rebalanced
+        continue;
+      }
+      Integer partitionId = 
SegmentUtils.getPartitionIdFromSegmentName(segment);
+      if (partitionId == null) {
+        tableRebalanceLogger.info("Found new segment: {} without partition id 
in its name, re-calculating the target "
+            + "assignment based on the new IdealState", segment);
+        return null;
+      }
+      List<String> currentInstances = 
currentPartitionAssignment.get(partitionId);
+      if (currentInstances == null || currentInstances.size() != 
currentInstanceStateMap.size()
+          || !currentInstanceStateMap.keySet().containsAll(currentInstances)) {
+        // Not assigned to the instances of its partition (e.g. relocated to 
the COMPLETED instances), keep it as is
+        continue;
+      }
+      newTargetAssignment.put(segment,
+          
SegmentAssignmentUtils.getInstanceStateMap(newTargetPartitionAssignment.get(partitionId),
 state));
+    }
+    return new TableAssignment(newTargetAssignment, 
newTargetPartitionAssignment);
+  }
+
   /// Uses the default LOGGER
   @VisibleForTesting
   static Map<String, Map<String, String>> getNextAssignment(Map<String, 
Map<String, String>> currentAssignment,
       Map<String, Map<String, String>> targetAssignment, int 
minAvailableReplicas, boolean enableStrictReplicaGroup,
-      boolean lowDiskMode, int batchSizePerServer, 
Object2IntOpenHashMap<String> segmentPartitionIdMap,
-      PartitionIdFetcher partitionIdFetcher, DataLossRiskAssessor 
dataLossRiskAssessor) {
-    return getNextAssignment(currentAssignment, targetAssignment, 
minAvailableReplicas, enableStrictReplicaGroup,
-        lowDiskMode, batchSizePerServer, segmentPartitionIdMap, 
partitionIdFetcher, dataLossRiskAssessor, LOGGER);
+      boolean lowDiskMode, int batchSizePerServer, DataLossRiskAssessor 
dataLossRiskAssessor) {
+    return getNextAssignment(currentAssignment, targetAssignment, Map.of(), 
Map.of(), minAvailableReplicas,
+        enableStrictReplicaGroup, lowDiskMode, batchSizePerServer, 
dataLossRiskAssessor).getSegmentAssignment();
+  }
+
+  /// Uses the default LOGGER
+  @VisibleForTesting
+  static TableAssignment getNextAssignment(Map<String, Map<String, String>> 
currentAssignment,
+      Map<String, Map<String, String>> targetAssignment, Map<Integer, 
List<String>> currentPartitionAssignment,
+      Map<Integer, List<String>> targetPartitionAssignment, int 
minAvailableReplicas, boolean enableStrictReplicaGroup,
+      boolean lowDiskMode, int batchSizePerServer, DataLossRiskAssessor 
dataLossRiskAssessor) {
+    return getNextAssignment(currentAssignment, targetAssignment, 
currentPartitionAssignment,
+        targetPartitionAssignment, minAvailableReplicas, 
enableStrictReplicaGroup, lowDiskMode, batchSizePerServer,
+        dataLossRiskAssessor, LOGGER);
   }
 
   /// Returns the next assignment for the table based on the current 
assignment and the target assignment with regard to
   /// the minimum available replicas requirement. For strict replica-group 
mode, track the available instances for all
   /// the segments with the same instances in the next assignment, and ensure 
the minimum available replicas requirement
   /// is met. If adding the assignment for a segment breaks the requirement, 
use the current assignment for the segment.
   ///
-  /// For strict replica group routing only (where the segment assignment is 
not StrictRealtimeSegmentAssignment)
-  /// if batching is enabled, the instances assigned for the same partitionId 
can be different for different segments.
-  /// For strict replica group routing with StrictRealtimeSegmentAssignment on 
the other hand, the assignment for a
-  /// given
-  /// partitionId will be the same across all segments. We can treat both 
cases similarly by creating a mapping from
-  /// partitionId -> unique set of instance assignments -> currentAssignment. 
With StrictRealtimeSegmentAssignment,
-  /// this map will have a single entry for 'unique set of instance 
assignments'.
-  ///
-  /// TODO: Ideally if strict replica group routing is enabled then 
StrictRealtimeSegmentAssignment should be used, but
-  ///       this is not enforced in the code today. Once enforcement is added, 
then the routing side and assignment side
-  ///       will be equivalent and all segments belonging to a given 
partitionId will be assigned to the same set of
-  ///       instances. Special handling to check each group of assigned 
instances can be removed in that case. The
-  ///       strict replica group routing can also be utilized for OFFLINE 
tables, thus StrictRealtimeSegmentAssignment
-  ///       also needs to be made more generic for the OFFLINE case.
-  private static Map<String, Map<String, String>> 
getNextAssignment(Map<String, Map<String, String>> currentAssignment,
-      Map<String, Map<String, String>> targetAssignment, int 
minAvailableReplicas, boolean enableStrictReplicaGroup,
-      boolean lowDiskMode, int batchSizePerServer, 
Object2IntOpenHashMap<String> segmentPartitionIdMap,
-      PartitionIdFetcher partitionIdFetcher, DataLossRiskAssessor 
dataLossRiskAssessor, Logger tableRebalanceLogger) {
+  /// The partition assignment (the instances of each partition of a 
partition-pinned table, see
+  /// [PartitionAssignmentUtils]) is moved along with the segments: a 
partition takes the same step as a segment with
+  /// the same current and target instances, so that a segment added to the 
partition during the rebalance is assigned
+  /// to the same instances as the other segments of the partition at that 
point. In strict replica-group mode, a
+  /// partition moves exactly when the segments with its current and target 
instances move. A partition of the target
+  /// without entry in the current partition assignment is left without entry, 
as its current instances are unknown:
+  /// the caller sets it once the segments reach the target. A partition of 
the current partition assignment absent
+  /// from the target is dropped.
+  private static TableAssignment getNextAssignment(Map<String, Map<String, 
String>> currentAssignment,
+      Map<String, Map<String, String>> targetAssignment, Map<Integer, 
List<String>> currentPartitionAssignment,
+      Map<Integer, List<String>> targetPartitionAssignment, int 
minAvailableReplicas, boolean enableStrictReplicaGroup,
+      boolean lowDiskMode, int batchSizePerServer, DataLossRiskAssessor 
dataLossRiskAssessor,
+      Logger tableRebalanceLogger) {
     return enableStrictReplicaGroup ? 
getNextStrictReplicaGroupAssignment(currentAssignment, targetAssignment,
-        minAvailableReplicas, lowDiskMode, batchSizePerServer, 
segmentPartitionIdMap, partitionIdFetcher,
+        currentPartitionAssignment, targetPartitionAssignment, 
minAvailableReplicas, lowDiskMode, batchSizePerServer,
         dataLossRiskAssessor, tableRebalanceLogger)
-        : getNextNonStrictReplicaGroupAssignment(currentAssignment, 
targetAssignment, minAvailableReplicas, lowDiskMode,
-            batchSizePerServer, dataLossRiskAssessor);
+        : getNextNonStrictReplicaGroupAssignment(currentAssignment, 
targetAssignment, currentPartitionAssignment,
+            targetPartitionAssignment, minAvailableReplicas, lowDiskMode, 
batchSizePerServer, dataLossRiskAssessor);
   }
 
-  private static Map<String, Map<String, String>> 
getNextStrictReplicaGroupAssignment(
+  private static TableAssignment getNextStrictReplicaGroupAssignment(
       Map<String, Map<String, String>> currentAssignment, Map<String, 
Map<String, String>> targetAssignment,
-      int minAvailableReplicas, boolean lowDiskMode, int batchSizePerServer,
-      Object2IntOpenHashMap<String> segmentPartitionIdMap, PartitionIdFetcher 
partitionIdFetcher,
-      DataLossRiskAssessor dataLossRiskAssessor, Logger tableRebalanceLogger) {
+      Map<Integer, List<String>> currentPartitionAssignment, Map<Integer, 
List<String>> targetPartitionAssignment,
+      int minAvailableReplicas, boolean lowDiskMode, int batchSizePerServer, 
DataLossRiskAssessor dataLossRiskAssessor,
+      Logger tableRebalanceLogger) {
     Map<String, Map<String, String>> nextAssignment = new TreeMap<>();
+    Map<Integer, List<String>> nextPartitionAssignment = new TreeMap<>();
     Map<String, Integer> numSegmentsToOffloadMap = 
getNumSegmentsToOffloadMap(currentAssignment, targetAssignment);
     Map<Pair<Set<String>, Set<String>>, Set<String>> assignmentMap = new 
HashMap<>();
     Map<Set<String>, Set<String>> availableInstancesMap = new HashMap<>();
     Map<String, Integer> serverToNumSegmentsAddedSoFar = new HashMap<>();
 
     if (batchSizePerServer == RebalanceConfig.DISABLE_BATCH_SIZE_PER_SERVER) {
       // Directly update the nextAssignment with anyServerExhaustedBatchSize = 
false and return if batching is disabled
-      updateNextAssignmentForPartitionIdStrictReplicaGroup(currentAssignment, 
targetAssignment, nextAssignment, false,
-          minAvailableReplicas, lowDiskMode, numSegmentsToOffloadMap, 
assignmentMap, availableInstancesMap,
-          serverToNumSegmentsAddedSoFar, dataLossRiskAssessor);
-      return nextAssignment;
-    }
-
-    // Batching is enabled, calculate the Pair(current instances, target 
instances) -> partitionId -> currentAssignment
-    Map<Pair<Set<String>, Set<String>>, Map<Integer, Map<String, Map<String, 
String>>>>
-        currentAndTargetInstancesToPartitionIdToCurrentAssignmentMap =
-        
getCurrentAndTargetInstancesToPartitionIdToCurrentAssignmentMap(currentAssignment,
 targetAssignment,
-            segmentPartitionIdMap, partitionIdFetcher);
-
-    // Iterating over the unique pairs of current and target instances
-    for (Map<Integer, Map<String, Map<String, String>>> 
partitionIdToCurrentAssignment
-        : 
currentAndTargetInstancesToPartitionIdToCurrentAssignmentMap.values()) {
-      // Check if the servers of the first assignment for each unique 
partition has any space left to move the
-      // segments assigned to the partition and unique assigned instances as a 
whole. If so, let's mark the partitions
-      // as to be moved, otherwise we mark the partition as a whole as not 
moveable.
-      // Iterating over the partitionIds with the same unique pair of current 
and assigned instances
-      for (Map<String, Map<String, String>> curAssignment : 
partitionIdToCurrentAssignment.values()) {
-        Map.Entry<String, Map<String, String>> firstEntry = 
curAssignment.entrySet().iterator().next();
-        // It is enough to check for whether any server for one segment is 
above the limit or not since all segments
-        // in curAssignment will have the same current and target instances 
and same partitionId
-        Map<String, String> firstEntryInstanceStateMap = firstEntry.getValue();
-        SingleSegmentAssignment firstAssignment =
-            getNextSingleSegmentAssignment(firstEntryInstanceStateMap, 
targetAssignment.get(firstEntry.getKey()),
-                minAvailableReplicas, lowDiskMode, numSegmentsToOffloadMap, 
assignmentMap);
-        Set<String> serversAdded =
-            
getServersAddedInSingleSegmentAssignment(firstEntryInstanceStateMap, 
firstAssignment._instanceStateMap);
-        boolean anyServerExhaustedBatchSize = false;
-        for (String server : serversAdded) {
-          int segmentsAddedToServerSoFar = 
serverToNumSegmentsAddedSoFar.getOrDefault(server, 0);
-          // Case I: We already exceeded the batchSizePerServer for this 
server, cannot add any more segments
-          // Case II: We have not yet exceeded the batchSizePerServer for this 
server, but we don't have sufficient
-          // space to host the segments for this assignment on the server, and 
we have allocated some partitions so
-          // far. If the batchSizePerServer is less than the number of 
segments in a given partitionId, we must host
-          // at least 1 partition and exceed the batchSizePerServer to ensure 
progress is made. Thus, performing this
-          // check only if segmentsAddedToServerSoFar > 0 is necessary.
-          if ((segmentsAddedToServerSoFar >= batchSizePerServer) || 
(segmentsAddedToServerSoFar > 0
-              && (segmentsAddedToServerSoFar + curAssignment.size()) > 
batchSizePerServer)) {
-            anyServerExhaustedBatchSize = true;
-            break;
-          }
+      StrictReplicaGroup group = new StrictReplicaGroup();
+      group._segments.putAll(currentAssignment);
+      for (int partitionId : currentPartitionAssignment.keySet()) {
+        if (targetPartitionAssignment.containsKey(partitionId)) {
+          group._partitionIds.add(partitionId);
         }
-        updateNextAssignmentForPartitionIdStrictReplicaGroup(curAssignment, 
targetAssignment, nextAssignment,
-            anyServerExhaustedBatchSize, minAvailableReplicas, lowDiskMode, 
numSegmentsToOffloadMap, assignmentMap,
-            availableInstancesMap, serverToNumSegmentsAddedSoFar, 
dataLossRiskAssessor);
       }
+      moveStrictReplicaGroup(group, false, targetAssignment, 
currentPartitionAssignment, targetPartitionAssignment,
+          nextAssignment, nextPartitionAssignment, minAvailableReplicas, 
lowDiskMode, numSegmentsToOffloadMap,
+          assignmentMap, availableInstancesMap, serverToNumSegmentsAddedSoFar, 
dataLossRiskAssessor);
+      return new TableAssignment(nextAssignment, nextPartitionAssignment);
+    }
+
+    // Batching is enabled, move the segments and the partitions group by group
+    for (StrictReplicaGroup group : getStrictReplicaGroups(currentAssignment, 
targetAssignment,
+        currentPartitionAssignment, targetPartitionAssignment)) {
+      // Check if the servers of the first assignment for the group have any 
space left to move the segments of the
+      // group as a whole. If so, let's mark the group as to be moved, 
otherwise we mark the group as a whole as not
+      // moveable. It is enough to check the first segment (or partition) 
since all segments and partitions in the
+      // group have the same current and target instances.
+      Map<String, String> firstCurrentInstanceStateMap;
+      Map<String, String> firstTargetInstanceStateMap;
+      if (!group._segments.isEmpty()) {
+        Map.Entry<String, Map<String, String>> firstEntry = 
group._segments.entrySet().iterator().next();
+        firstCurrentInstanceStateMap = firstEntry.getValue();
+        firstTargetInstanceStateMap = 
targetAssignment.get(firstEntry.getKey());
+      } else {
+        int firstPartitionId = group._partitionIds.get(0);
+        firstCurrentInstanceStateMap = 
getPartitionInstanceStateMap(currentPartitionAssignment.get(firstPartitionId));
+        firstTargetInstanceStateMap = 
getPartitionInstanceStateMap(targetPartitionAssignment.get(firstPartitionId));
+      }
+      SingleSegmentAssignment firstAssignment =
+          getNextSingleSegmentAssignment(firstCurrentInstanceStateMap, 
firstTargetInstanceStateMap,
+              minAvailableReplicas, lowDiskMode, numSegmentsToOffloadMap, 
assignmentMap);
+      Set<String> serversAdded =
+          
getServersAddedInSingleSegmentAssignment(firstCurrentInstanceStateMap, 
firstAssignment._instanceStateMap);
+      int numSegments = group._segments.size();
+      boolean anyServerExhaustedBatchSize = false;
+      for (String server : serversAdded) {
+        int segmentsAddedToServerSoFar = 
serverToNumSegmentsAddedSoFar.getOrDefault(server, 0);
+        // Case I: We already exceeded the batchSizePerServer for this server, 
cannot add any more segments
+        // Case II: We have not yet exceeded the batchSizePerServer for this 
server, but we don't have sufficient
+        // space to host the segments for this assignment on the server, and 
we have allocated some partitions so
+        // far. If the batchSizePerServer is less than the number of segments 
in a given partitionId, we must host
+        // at least 1 partition and exceed the batchSizePerServer to ensure 
progress is made. Thus, performing this
+        // check only if segmentsAddedToServerSoFar > 0 is necessary.
+        if ((segmentsAddedToServerSoFar >= batchSizePerServer) || 
(segmentsAddedToServerSoFar > 0
+            && (segmentsAddedToServerSoFar + numSegments) > 
batchSizePerServer)) {
+          anyServerExhaustedBatchSize = true;
+          break;
+        }
+      }
+      moveStrictReplicaGroup(group, anyServerExhaustedBatchSize, 
targetAssignment, currentPartitionAssignment,
+          targetPartitionAssignment, nextAssignment, nextPartitionAssignment, 
minAvailableReplicas, lowDiskMode,
+          numSegmentsToOffloadMap, assignmentMap, availableInstancesMap, 
serverToNumSegmentsAddedSoFar,
+          dataLossRiskAssessor);
     }
 
     checkIfAnyServersAssignedMoreSegmentsThanBatchSize(batchSizePerServer, 
serverToNumSegmentsAddedSoFar,
         tableRebalanceLogger);
-    return nextAssignment;
+    return new TableAssignment(nextAssignment, nextPartitionAssignment);
   }
 
-  private static void updateNextAssignmentForPartitionIdStrictReplicaGroup(
-      Map<String, Map<String, String>> currentAssignment, Map<String, 
Map<String, String>> targetAssignment,
-      Map<String, Map<String, String>> nextAssignment, boolean 
anyServerExhaustedBatchSize, int minAvailableReplicas,
-      boolean lowDiskMode, Map<String, Integer> numSegmentsToOffloadMap,
-      Map<Pair<Set<String>, Set<String>>, Set<String>> assignmentMap,
+  /// Moves the segments and the partitions of the given group to their next 
assignment, or keeps them as they are
+  /// when the batch size is exhausted. The segments and partitions of a group 
have the same current and target
+  /// instances, so they take the same step; the minimum available replicas 
check is still performed per segment and
+  /// partition because it depends on the other segments assigned to the same 
instances.
+  private static void moveStrictReplicaGroup(StrictReplicaGroup group, boolean 
anyServerExhaustedBatchSize,
+      Map<String, Map<String, String>> targetAssignment, Map<Integer, 
List<String>> currentPartitionAssignment,
+      Map<Integer, List<String>> targetPartitionAssignment, Map<String, 
Map<String, String>> nextAssignment,
+      Map<Integer, List<String>> nextPartitionAssignment, int 
minAvailableReplicas, boolean lowDiskMode,
+      Map<String, Integer> numSegmentsToOffloadMap, Map<Pair<Set<String>, 
Set<String>>, Set<String>> assignmentMap,
       Map<Set<String>, Set<String>> availableInstancesMap, Map<String, 
Integer> serverToNumSegmentsAddedSoFar,
       DataLossRiskAssessor dataLossRiskAssessor) {
     if (anyServerExhaustedBatchSize) {
-      // Exhausted the batch size for at least 1 server, just copy over the 
remaining segments as is
-      nextAssignment.putAll(currentAssignment);
-    } else {
-      // Process all the partitionIds even if segmentsAddedSoFar becomes 
larger than batchSizePerServer
-      // Can only do bestEfforts w.r.t. StrictReplicaGroup since a whole 
partition must be moved together for
-      // maintaining consistency
-      for (Map.Entry<String, Map<String, String>> entry : 
currentAssignment.entrySet()) {
-        String segmentName = entry.getKey();
-        Map<String, String> currentInstanceStateMap = entry.getValue();
-        Map<String, String> targetInstanceStateMap = 
targetAssignment.get(segmentName);
-        SingleSegmentAssignment assignment =
-            getNextSingleSegmentAssignment(currentInstanceStateMap, 
targetInstanceStateMap, minAvailableReplicas,
-                lowDiskMode, numSegmentsToOffloadMap, assignmentMap);
-        Set<String> assignedInstances = assignment._instanceStateMap.keySet();
-        Set<String> availableInstances = assignment._availableInstances;
-        availableInstancesMap.compute(assignedInstances, (k, 
currentAvailableInstances) -> {
-          if (currentAvailableInstances == null) {
-            // First segment assigned to these instances, use the new 
assignment and update the available instances
-            nextAssignment.put(segmentName, assignment._instanceStateMap);
-            updateNumSegmentsToOffloadMap(numSegmentsToOffloadMap, 
currentInstanceStateMap.keySet(), k);
-            return availableInstances;
-          } else {
-            // There are other segments assigned to the same instances, check 
the available instances to see if
-            // adding the new assignment can still hold the minimum available 
replicas requirement
-            availableInstances.retainAll(currentAvailableInstances);
-            if (availableInstances.size() >= minAvailableReplicas) {
-              // New assignment can be added
-              nextAssignment.put(segmentName, assignment._instanceStateMap);
-              updateNumSegmentsToOffloadMap(numSegmentsToOffloadMap, 
currentInstanceStateMap.keySet(), k);
-              return availableInstances;
-            } else {
-              // New assignment cannot be added, use the current instance 
state map
-              nextAssignment.put(segmentName, currentInstanceStateMap);
-              return currentAvailableInstances;
-            }
-          }
-        });
+      // Exhausted the batch size for at least 1 server, just copy over the 
remaining segments and partitions as is
+      nextAssignment.putAll(group._segments);
+      for (int partitionId : group._partitionIds) {
+        nextPartitionAssignment.put(partitionId, 
currentPartitionAssignment.get(partitionId));
+      }
+      return;
+    }
+    // Process all the segments even if segmentsAddedSoFar becomes larger than 
batchSizePerServer
+    // Can only do bestEfforts w.r.t. StrictReplicaGroup since a whole 
partition must be moved together for
+    // maintaining consistency
+    for (Map.Entry<String, Map<String, String>> entry : 
group._segments.entrySet()) {
+      String segmentName = entry.getKey();
+      Map<String, String> currentInstanceStateMap = entry.getValue();
+      Map<String, String> targetInstanceStateMap = 
targetAssignment.get(segmentName);
+      SingleSegmentAssignment assignment =
+          getNextSingleSegmentAssignment(currentInstanceStateMap, 
targetInstanceStateMap, minAvailableReplicas,
+              lowDiskMode, numSegmentsToOffloadMap, assignmentMap);
+      Map<String, String> nextInstanceStateMap;
+      if (canAssignWithStrictReplicaGroup(assignment, minAvailableReplicas, 
availableInstancesMap)) {
+        nextInstanceStateMap = assignment._instanceStateMap;
+        updateNumSegmentsToOffloadMap(numSegmentsToOffloadMap, 
currentInstanceStateMap.keySet(),
+            nextInstanceStateMap.keySet());
+      } else {
+        nextInstanceStateMap = currentInstanceStateMap;
+      }
+      nextAssignment.put(segmentName, nextInstanceStateMap);
 
-        if (!nextAssignment.get(segmentName).equals(currentInstanceStateMap)) {
-          Set<String> serversAddedForSegment =
-              
getServersAddedInSingleSegmentAssignment(currentInstanceStateMap, 
nextAssignment.get(segmentName));
-          serversAddedForSegment.forEach(server -> 
serverToNumSegmentsAddedSoFar.merge(server, 1, Integer::sum));
+      if (!nextInstanceStateMap.equals(currentInstanceStateMap)) {
+        Set<String> serversAddedForSegment =
+            getServersAddedInSingleSegmentAssignment(currentInstanceStateMap, 
nextInstanceStateMap);
+        serversAddedForSegment.forEach(server -> 
serverToNumSegmentsAddedSoFar.merge(server, 1, Integer::sum));
 
-          // Since next assignment doesn't match current assignment, it means 
the segment will be moved. Check if there
-          // is a data loss risk
-          Pair<Boolean, String> dataLossRiskResult = 
dataLossRiskAssessor.assessDataLossRisk(segmentName);
-          if (dataLossRiskResult.getLeft()) {
-            throw new IllegalStateException(dataLossRiskResult.getRight());
-          }
+        // Since next assignment doesn't match current assignment, it means 
the segment will be moved. Check if there
+        // is a data loss risk
+        Pair<Boolean, String> dataLossRiskResult = 
dataLossRiskAssessor.assessDataLossRisk(segmentName);
+        if (dataLossRiskResult.getLeft()) {
+          throw new IllegalStateException(dataLossRiskResult.getRight());
         }
       }
     }
+    for (int partitionId : group._partitionIds) {
+      List<String> currentInstances = 
currentPartitionAssignment.get(partitionId);
+      SingleSegmentAssignment assignment =
+          
getNextSingleSegmentAssignment(getPartitionInstanceStateMap(currentInstances),
+              
getPartitionInstanceStateMap(targetPartitionAssignment.get(partitionId)), 
minAvailableReplicas,
+              lowDiskMode, numSegmentsToOffloadMap, assignmentMap);
+      nextPartitionAssignment.put(partitionId,
+          canAssignWithStrictReplicaGroup(assignment, minAvailableReplicas, 
availableInstancesMap)

Review Comment:
   **Data-less partition entries take part in the min-available-replicas 
accounting like real segments.** `canAssignWithStrictReplicaGroup` registers an 
entry's available instances in the shared `availableInstancesMap`, so with 
batching on (groups iterated in HashMap order) an entry-only group processed 
first can hold back real segments heading to the same next instance set. 
Example: minAvailableReplicas=1, partition 1's entry [p,q]->[p,r] with no 
segments in that pair, partition 3's segments [r,s]->[p,r]; both step to [p,r] 
with available {p} vs {r}; if partition 1's group comes first, partition 3's 
segments fail `retainAll` and stay put for an extra round although no real 
segment constrains them. Deriving an entry's next step from its segments' next 
assignment would avoid this.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/assignment/segment/OfflineSegmentAssignment.java:
##########
@@ -21,47 +21,97 @@
 import com.google.common.base.Preconditions;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeMap;
 import javax.annotation.Nullable;
 import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.lang3.tuple.Pair;
+import org.apache.helix.model.IdealState;
 import org.apache.pinot.common.assignment.InstancePartitions;
+import org.apache.pinot.common.assignment.InstancePartitionsUtils;
+import org.apache.pinot.common.assignment.PartitionAssignmentUtils;
 import org.apache.pinot.common.restlet.resources.RebalanceConfig;
 import org.apache.pinot.common.tier.Tier;
+import org.apache.pinot.common.utils.SegmentUtils;
 import 
org.apache.pinot.controller.helix.core.assignment.segment.strategy.AllServersSegmentAssignmentStrategy;
 import 
org.apache.pinot.controller.helix.core.assignment.segment.strategy.SegmentAssignmentStrategy;
 import 
org.apache.pinot.controller.helix.core.assignment.segment.strategy.SegmentAssignmentStrategyFactory;
+import org.apache.pinot.spi.config.table.TableConfig;
 import org.apache.pinot.spi.config.table.assignment.InstancePartitionsType;
+import 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel;
 
 
 /// Segment assignment for offline table.
+///
+/// For a partition-pinned table (strict replica-group routing), all segments 
of a value partition are assigned to
+/// the same instances, recorded in the partition assignment of the ideal 
state (see
+/// [org.apache.pinot.common.assignment.PartitionAssignmentUtils]), even when 
the instance partitions have changed.
+/// The partition id of a new segment is read from its ZK metadata. This 
requires a partition column, a table that is
+/// not a dimension table (whose segments are assigned to all servers of the 
tenant), and OFFLINE instance partitions
+/// holding exactly one instance per partition within each replica-group; 
otherwise a warning is logged and the
+/// segments are assigned with the segment assignment strategy, as are 
segments without partition id and segments
+/// eligible for a tier.
 public class OfflineSegmentAssignment extends BaseSegmentAssignment {
 
   @Override
-  public List<String> assignSegment(String segmentName, Map<String, 
Map<String, String>> currentAssignment,
+  protected boolean isPartitionPinned(TableConfig tableConfig) {
+    if (!super.isPartitionPinned(tableConfig)) {
+      return false;
+    }
+    if (_partitionColumn == null) {
+      _logger.warn("Table: {} with strict replica-group routing has no 
partition column, cannot pin its segments to "
+          + "their partition, assigning them with the segment assignment 
strategy", _tableNameWithType);
+      return false;
+    }
+    if (tableConfig.isDimTable()) {
+      _logger.warn("Dimension table: {} with strict replica-group routing 
cannot pin its segments to their partition "
+          + "because they are assigned to all servers, assigning them with the 
segment assignment strategy",
+          _tableNameWithType);
+      return false;
+    }
+    return true;
+  }
+
+  @Override
+  public List<String> assignSegment(String segmentName, IdealState idealState,
       Map<InstancePartitionsType, InstancePartitions> instancePartitionsMap) {
     InstancePartitions instancePartitions = 
instancePartitionsMap.get(InstancePartitionsType.OFFLINE);
     Preconditions.checkState(instancePartitions != null, "Failed to find 
OFFLINE instance partitions for table: %s",
         _tableNameWithType);
-    // Gets Segment assignment strategy for instance partitions
-    SegmentAssignmentStrategy segmentAssignmentStrategy =
-        
SegmentAssignmentStrategyFactory.getSegmentAssignmentStrategy(_helixManager, 
_tableConfig,
-            InstancePartitionsType.OFFLINE.toString(), instancePartitions);
     _logger.info("Assigning segment: {} with instance partitions: {} for 
table: {}", segmentName, instancePartitions,
         _tableNameWithType);
-    List<String> instancesAssigned =
-        segmentAssignmentStrategy.assignSegment(segmentName, 
currentAssignment, instancePartitions);
+    List<String> instancesAssigned = null;
+    if (isPinnedWith(instancePartitions)) {
+      Integer partitionId =
+          SegmentUtils.getSegmentPartitionId(segmentName, _tableNameWithType, 
_helixManager, _partitionColumn);
+      if (partitionId != null) {
+        instancesAssigned = getOrCreatePartitionInstances(idealState, 
partitionId,

Review Comment:
   **OfflineSegmentAssignment does not override `getExistingAssignment`, so for 
offline tables created before this PR a partition's first entry is seeded from 
the current instance partitions rather than from where its segments are.** That 
contradicts the class doc ('even when the instance partitions have changed'), 
and the realtime side has this handling. Example: pre-existing pinned OFFLINE 
(e.g. upsert) table without entries: replaceInstance X->Y without a rebalance, 
then the first upload of partition P stores entry [Y]; the operator reverts the 
instance partitions to X, but every later upload of P still goes to Y (base: 
back to X), so P stays split with a growing share on Y until a rebalance.



##########
pinot-common/src/main/java/org/apache/pinot/common/assignment/PartitionAssignmentUtils.java:
##########
@@ -0,0 +1,102 @@
+/**
+ * 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.pinot.common.assignment;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+import org.apache.helix.model.IdealState;
+
+
+/// Partition assignment of a table: the instances hosting all segments of 
each segment partition, stored in the list
+/// fields of the table IdealState. List fields whose key is not a partition 
id are not part of it and are left alone.
+///
+/// Each list field maps a segment partition id (the stream partition for a 
real-time table, the value partition for an
+/// offline table) to the sorted list of instances hosting all segments of 
that partition. The partition assignment is
+/// maintained for partition-pinned tables only, i.e. tables whose segments 
must be collocated per partition: tables
+/// with strict replica-group routing, which upsert and dedup tables use. For 
these tables:
+/// - A new segment is assigned to the instances of its partition when the 
partition has an entry, even when the
+///   instance partitions have changed since the entry was created. The entry 
is created from the instance partitions
+///   otherwise.
+/// - Table rebalance moves the entries together with the segments, so that a 
segment added during the rebalance is
+///   assigned to the same instances as the other segments of its partition at 
that point of the rebalance.
+///
+/// The entries are read and written together with the map fields of the 
IdealState, so they are never observed out of
+/// sync with the segment assignment. Helix ignores the list fields of an 
IdealState in CUSTOMIZED rebalance mode.
+public class PartitionAssignmentUtils {
+  private PartitionAssignmentUtils() {
+  }
+
+  /// Returns the instances of the given partition, or `null` if the partition 
has no entry.
+  @Nullable
+  public static List<String> getInstances(IdealState idealState, int 
partitionId) {
+    return idealState.getRecord().getListField(Integer.toString(partitionId));
+  }
+
+  /// Sets the instances of the given partition.
+  public static void setInstances(IdealState idealState, int partitionId, 
Collection<String> instances) {
+    idealState.getRecord().setListField(Integer.toString(partitionId), 
toInstanceList(instances));
+  }
+
+  /// Returns the partition assignment as a sorted map from partition id to 
the instances of the partition. The lists
+  /// are the ones stored in the IdealState record and must not be modified.
+  public static Map<Integer, List<String>> getPartitionAssignment(IdealState 
idealState) {
+    Map<Integer, List<String>> partitionAssignment = new TreeMap<>();
+    for (Map.Entry<String, List<String>> entry : 
idealState.getRecord().getListFields().entrySet()) {
+      try {
+        partitionAssignment.put(Integer.parseInt(entry.getKey()), 
entry.getValue());

Review Comment:
   **Any list field whose key passes lenient `Integer.parseInt` is treated as a 
partition entry, including negative keys.** A hand-edited `-1` key makes every 
`rebalanceTable` throw while seeding the target: 
`OfflineSegmentAssignment.getPartitionInstances(-1)` computes `-1 % 
numPartitions = -1`, so `getInstances(-1, 0)` is null and NPEs; 
`RealtimeSegmentAssignment.assignConsumingSegment(-1, ...)` throws 
IndexOutOfBounds. The rebalance returns FAILED and nothing removes the key. A 
JSON-null list value similarly NPEs at `new HashSet<>(entry.getValue())`. Only 
canonical non-negative keys (`Integer.toString(id)`, as `getInstances` writes) 
should be accepted.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to