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


##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/assignment/segment/BaseSegmentAssignment.java:
##########
@@ -82,21 +101,95 @@ public abstract class BaseSegmentAssignment implements 
SegmentAssignment {
   @Nullable
   private Map<String, String> _segmentToTierName;
 
+  // Cache of segment name to partition id (null value means the partition id 
cannot be determined) for the segments
+  // whose partition id is not in the segment name, loaded with a single bulk 
ZK read on first use. Same scope and
+  // limitations as _segmentToTierName; a segment absent from the cache is 
resolved on demand and merged in.
+  @Nullable
+  private Map<String, Integer> _segmentToPartitionId;
+
   @Override
   public void init(HelixManager helixManager, TableConfig tableConfig, 
@Nullable ControllerMetrics controllerMetrics) {
     _helixManager = helixManager;
     _tableNameWithType = tableConfig.getTableName();
     _tableConfig = tableConfig;
     _replication = tableConfig.getReplication();
     _partitionColumn = TableConfigUtils.getPartitionColumn(_tableConfig);
-    _controllerMetrics = controllerMetrics;
+    _partitionPinned = isPartitionPinned(tableConfig);
     if (_partitionColumn == null) {
-      _logger.info("Initialized with replication: {} without partition column 
for table: {} ", _replication,
-          _tableNameWithType);
+      _logger.info("Initialized with replication: {} without partition column, 
partitionPinned: {} for table: {}",
+          _replication, _partitionPinned, _tableNameWithType);
     } else {
-      _logger.info("Initialized with replication: {} and partition column: {} 
for table: {}", _replication,
-          _partitionColumn, _tableNameWithType);
+      _logger.info("Initialized with replication: {} and partition column: {}, 
partitionPinned: {} for table: {}",
+          _replication, _partitionColumn, _partitionPinned, 
_tableNameWithType);
+    }
+  }
+
+  /// Returns whether the segments of the table are pinned to their partition 
(see the class Javadoc): the table uses
+  /// strict replica-group routing. Subclasses add the conditions they need to 
pin the segments, and log a warning
+  /// when such a table cannot be pinned, in which case its segments are 
assigned with the segment assignment strategy.
+  protected boolean isPartitionPinned(TableConfig tableConfig) {
+    return SegmentAssignmentUtils.isStrictReplicaGroupRouting(tableConfig);
+  }
+
+  /// Returns the instances of the given partition of a partition-pinned 
table, creating its entry in the partition
+  /// assignment of the given ideal state when the partition has no entry yet, 
or an empty one (a rebalance in low disk
+  /// mode without minimum available replicas empties the entry while it drops 
the partition from its current
+  /// instances). The entry is created from the existing assignment of the 
partition (see [#getExistingAssignment])
+  /// when there is one, else from the given candidate instances computed from 
the instance partitions. An existing
+  /// entry wins over the candidate instances, so that the segments of the 
partition stay collocated when the instance
+  /// partitions change: the partition moves on the next rebalance.
+  protected List<String> getOrCreatePartitionInstances(IdealState idealState, 
int partitionId,
+      List<String> candidateInstances) {
+    List<String> instances = PartitionAssignmentUtils.getInstances(idealState, 
partitionId);
+    if (CollectionUtils.isEmpty(instances)) {

Review Comment:
   **A non-empty entry is trusted unconditionally, but only the new 
TableRebalancer keeps entries in sync with segment moves.** Entries are written 
on the first commit per partition (not only on rebalance), and any move made 
without the new rebalancer leaves them stale; they then redirect every new 
segment of the partition. Example: a new-version controller writes entry 
`0`=[S1,S2]; controllers are rolled back (or an old-version controller runs a 
rebalance during a rolling upgrade) and the old TableRebalancer, which only 
calls `setMapFields`, moves partition 0 to [S2,S3]; after (re-)upgrade every 
new CONSUMING segment of partition 0 goes to [S1,S2] (S1 possibly 
decommissioned) while its history is on [S2,S3], splitting the upsert/dedup 
partition until a new-version rebalance. The removed mismatch meter was the 
only signal. CLAUDE.md asks to preserve mixed-version compatibility; consider 
validating the entry against the partition's existing segments (e.g. its 
CONSUMING segment) before
  trusting it.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/assignment/segment/BaseSegmentAssignment.java:
##########
@@ -82,21 +101,95 @@ public abstract class BaseSegmentAssignment implements 
SegmentAssignment {
   @Nullable
   private Map<String, String> _segmentToTierName;
 
+  // Cache of segment name to partition id (null value means the partition id 
cannot be determined) for the segments
+  // whose partition id is not in the segment name, loaded with a single bulk 
ZK read on first use. Same scope and
+  // limitations as _segmentToTierName; a segment absent from the cache is 
resolved on demand and merged in.
+  @Nullable
+  private Map<String, Integer> _segmentToPartitionId;
+
   @Override
   public void init(HelixManager helixManager, TableConfig tableConfig, 
@Nullable ControllerMetrics controllerMetrics) {
     _helixManager = helixManager;
     _tableNameWithType = tableConfig.getTableName();
     _tableConfig = tableConfig;
     _replication = tableConfig.getReplication();
     _partitionColumn = TableConfigUtils.getPartitionColumn(_tableConfig);
-    _controllerMetrics = controllerMetrics;
+    _partitionPinned = isPartitionPinned(tableConfig);
     if (_partitionColumn == null) {
-      _logger.info("Initialized with replication: {} without partition column 
for table: {} ", _replication,
-          _tableNameWithType);
+      _logger.info("Initialized with replication: {} without partition column, 
partitionPinned: {} for table: {}",
+          _replication, _partitionPinned, _tableNameWithType);
     } else {
-      _logger.info("Initialized with replication: {} and partition column: {} 
for table: {}", _replication,
-          _partitionColumn, _tableNameWithType);
+      _logger.info("Initialized with replication: {} and partition column: {}, 
partitionPinned: {} for table: {}",
+          _replication, _partitionColumn, _partitionPinned, 
_tableNameWithType);
+    }
+  }
+
+  /// Returns whether the segments of the table are pinned to their partition 
(see the class Javadoc): the table uses
+  /// strict replica-group routing. Subclasses add the conditions they need to 
pin the segments, and log a warning
+  /// when such a table cannot be pinned, in which case its segments are 
assigned with the segment assignment strategy.
+  protected boolean isPartitionPinned(TableConfig tableConfig) {
+    return SegmentAssignmentUtils.isStrictReplicaGroupRouting(tableConfig);

Review Comment:
   **Upsert/dedup tables on `multiStageReplicaGroup` routing silently lose 
partition pinning.** `TableConfigUtils.UPSERT_DEDUP_ALLOWED_ROUTING_STRATEGIES` 
still accepts that routing, and the deleted factory pinned every upsert/dedup 
table regardless of routing. Example: such a table's CONSUMING instance 
partitions change without moving segments (assignInstances, PUT 
instancePartitions, replaceInstance, or a reassignInstances rebalance that 
persists new instance partitions and then fails/stops); the next commit of 
partition P runs `assignConsumingSegment` from the instance partitions and puts 
the new CONSUMING segment on the new server while P's completed segments stay 
on the old one, so upsert serves stale and new rows for the same key and dedup 
admits duplicates. The PR calls this deliberate, but nothing rejects, warns 
about or migrates the still-valid config.



##########
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) {

Review Comment:
   **With includeConsuming=false on a pinned non-upsert table, CONSUMING 
segments created mid-rebalance are stranded on intermediate instances.** The 
partition entry still moves step by step (its target comes from the instance 
partitions), new CONSUMING segments are assigned to the entry at that moment, 
and both this `continue` and the full recompute 
(`newAssignment.putAll(consumingSegmentAssignment)`) leave them there. Example: 
non-upsert strictReplicaGroup table rebalanced [A,B]->[C,D] with 
includeConsuming=false (RebalanceConfig and SegmentRelocator default) and 
minAvailableReplicas=1: step 1 writes entry [A,C] ([A] in lowDiskMode); p0__6 
is created CONSUMING on {A,C}; the rebalance reports DONE with p0__6 on A 
(being decommissioned) or with a single replica, and after it commits it stays 
split from the rest of p0 on [C,D]. Before the PR the new segment went straight 
to the new instance partitions {C,D}.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/rebalance/TableRebalancer.java:
##########
@@ -1803,71 +1943,63 @@ private static void 
checkIfAnyServersAssignedMoreSegmentsThanBatchSize(int batch
     }
   }
 
-  /// Create a mapping of Pair(currentInstances, targetInstances) to 
partitionId to the current assignment of segments.
-  /// This is to be used for batching purposes for StrictReplicaGroup routing, 
for all segment assignment types:
-  /// RealtimeSegmentAssignment, StrictRealtimeSegmentAssignment and 
OfflineSegmentAssignment
-  /// @param currentAssignment the current assignment
-  /// @param targetAssignment the target assignment
-  /// @param segmentPartitionIdMap cache to store the partition ids to avoid 
fetching ZK segment metadata
-  /// @param partitionIdFetcher function to fetch the partition id
-  /// @return a mapping from Pair(currentInstances, targetInstances) to the 
partitionId to the segment assignment map of
-  ///         all segments that fall in that category
-  private static Map<Pair<Set<String>, Set<String>>, Map<Integer, Map<String, 
Map<String, String>>>>
-      getCurrentAndTargetInstancesToPartitionIdToCurrentAssignmentMap(
-          Map<String, Map<String, String>> currentAssignment, Map<String, 
Map<String, String>> targetAssignment,
-          Object2IntOpenHashMap<String> segmentPartitionIdMap, 
PartitionIdFetcher partitionIdFetcher) {
-    Map<Pair<Set<String>, Set<String>>, Map<Integer, Map<String, Map<String, 
String>>>>
-        currentAndTargetInstancesToPartitionIdToCurrentAssignmentMap = new 
HashMap<>();
-
-    for (Map.Entry<String, Map<String, String>> assignment : 
currentAssignment.entrySet()) {
-      String segmentName = assignment.getKey();
-      Map<String, String> currentInstanceStateMap = assignment.getValue();
-      Map<String, String> targetInstanceStateMap = 
targetAssignment.get(segmentName);
-
-      int partitionId = segmentPartitionIdMap.computeIfAbsent(segmentName, v 
-> partitionIdFetcher.fetch(segmentName));
-      Pair<Set<String>, Set<String>> currentAndTargetInstances =
-          Pair.of(currentInstanceStateMap.keySet(), 
targetInstanceStateMap.keySet());
-      
currentAndTargetInstancesToPartitionIdToCurrentAssignmentMap.computeIfAbsent(currentAndTargetInstances,
-              k -> new TreeMap<>())
-          .computeIfAbsent(partitionId, k -> new TreeMap<>())
-          .put(segmentName, currentInstanceStateMap);
-    }
-
-    return currentAndTargetInstancesToPartitionIdToCurrentAssignmentMap;
-  }
-
-  @VisibleForTesting
-  @FunctionalInterface
-  interface PartitionIdFetcher {
-    int fetch(String segmentName);
+  /// Segments and partitions with the same current and target instances, 
which are moved together for strict
+  /// replica-group routing.
+  private static class StrictReplicaGroup {
+    final Map<String, Map<String, String>> _segments = new TreeMap<>();
+    final List<Integer> _partitionIds = new ArrayList<>();
   }
 
-  private static class PartitionIdFetcherImpl implements PartitionIdFetcher {
-    private final String _tableNameWithType;
-    private final String _partitionColumn;
-    private final HelixManager _helixManager;
-    private final boolean _isStrictRealtimeSegmentAssignment;
-
-    private PartitionIdFetcherImpl(String tableNameWithType, @Nullable String 
partitionColumn,
-        HelixManager helixManager, boolean isStrictRealtimeSegmentAssignment) {
-      _tableNameWithType = tableNameWithType;
-      _partitionColumn = partitionColumn;
-      _helixManager = helixManager;
-      _isStrictRealtimeSegmentAssignment = isStrictRealtimeSegmentAssignment;
-    }
-
-    @Override
-    public int fetch(String segmentName) {
-      Integer partitionId =
-          SegmentUtils.getSegmentPartitionId(segmentName, _tableNameWithType, 
_helixManager, _partitionColumn);
-      if (partitionId != null) {
-        return partitionId;
+  /// Groups the segments and the partitions by their current and target 
instances for batching with strict
+  /// replica-group routing, for all table types. Within a pair of current and 
target instances, the segments are
+  /// further grouped by the partition id in their name, so that partitions 
sharing the same instances can be moved in
+  /// different batches. When a segment has no partition id in its name (a 
segment of an offline table, or an uploaded
+  /// segment with a custom name), the whole pair is moved together so that 
the segment stays with the other segments
+  /// of its partition. The partitions of a partition-pinned table join the 
group of their current and target
+  /// instances. Partitions absent from the target partition assignment are 
dropped.
+  private static List<StrictReplicaGroup> getStrictReplicaGroups(Map<String, 
Map<String, String>> currentAssignment,
+      Map<String, Map<String, String>> targetAssignment, Map<Integer, 
List<String>> currentPartitionAssignment,
+      Map<Integer, List<String>> targetPartitionAssignment) {
+    Map<Pair<Set<String>, Set<String>>, Map<Integer, StrictReplicaGroup>> 
groupsMap = new HashMap<>();
+    for (Map.Entry<String, Map<String, String>> entry : 
currentAssignment.entrySet()) {
+      String segmentName = entry.getKey();
+      Map<String, String> currentInstanceStateMap = entry.getValue();
+      Pair<Set<String>, Set<String>> currentAndTargetInstances =
+          Pair.of(currentInstanceStateMap.keySet(), 
targetAssignment.get(segmentName).keySet());
+      Integer partitionId = 
SegmentUtils.getPartitionIdFromSegmentName(segmentName);

Review Comment:
   **batchSizePerServer no longer limits OFFLINE strictReplicaGroup 
rebalances.** The partition id comes from the segment name only; offline names 
never carry one, so every segment maps to UNKNOWN_PARTITION_ID and the merge 
below moves the whole (current,target) pair as one group, which is always 
admitted because the first group reaching a server has 
`segmentsAddedToServerSoFar == 0`. Example: offline strictReplicaGroup table 
with replica-group instance partitions, batchSizePerServer=100, replacing 
server X with X': all ~2,500 segments of the pair ({X,Y},{X',Y}) are added to 
X' in one IdealState step (only a WARN is logged), where the removed 
PartitionIdFetcher grouped by ZK partition id or a per-segment hash and moved 
~100 per step. One custom-named segment does the same to a realtime pinned 
pair. The partition ids that rebalanceTable already resolved 
(`_segmentToPartitionId`) could be returned and used here, with a per-segment 
default for unpinned tables.



##########
pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/rebalance/TableRebalancer.java:
##########
@@ -396,6 +402,30 @@ private RebalanceResult doRebalance(TableConfig 
tableConfig, RebalanceConfig reb
 
     if (segmentAssignmentUnchanged) {
       tableRebalanceLogger.info("Table is already balanced");
+      if (!partitionAssignmentUnchanged && !dryRun) {
+        // Only the partition assignment changed (e.g. a table created before 
the partition assignment was introduced,
+        // or partitions without segment): update it without moving any segment
+        tableRebalanceLogger.info("Updating the partition assignment from: {} 
to: {}", currentPartitionAssignment,
+            targetPartitionAssignment);
+        PartitionAssignmentUtils.setPartitionAssignment(currentIdealState, 
targetPartitionAssignment);
+        ZNRecord idealStateRecord = currentIdealState.getRecord();
+        try {
+          Preconditions.checkState(_helixDataAccessor.getBaseDataAccessor()
+              .set(idealStatePropertyKey.getPath(), idealStateRecord, 
idealStateRecord.getVersion(),

Review Comment:
   **The partition-only update writes with the version read at the start of 
doRebalance and turns a conflict into FAILED with no retry; the dry-run 
meanwhile reports NO_OP.** Between the read (~287) and this write run 
instance-partition computation, rebalanceTable (bulk ZK reads), 
fetchTableSizeDetails (up to 30s) and the summary, so on a busy table any 
commit makes `set(...)` throw ZkBadVersionException and the catch returns 
FAILED, where the base returned NO_OP. Example: pre-PR upsert table, segments 
balanced, an idle or paused partition without entry: a tenant rebalance or REST 
downtime=true rebalance fails and every rerun races again. The default REST 
flow runs a dry-run first, gets NO_OP and stops, so the PR's 'first rebalance 
populates them' never happens through it. Consider routing partition-only 
changes through the retrying main loop.



-- 
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