This is an automated email from the ASF dual-hosted git repository.

gortiz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 129fa937f34 Make broker routing components update incrementally on 
assignment changes (#19783)
129fa937f34 is described below

commit 129fa937f34492c07b734dcd51d9a9b57f718e44
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Thu Oct 15 10:20:03 2026 +0200

    Make broker routing components update incrementally on assignment changes 
(#19783)
    
    Part of #19782.
---
 .../SegmentPartitionMetadataManager.java           | 117 +++++++++++++++---
 .../segmentpartition/SegmentPartitionUtils.java    |  66 +++++++++-
 .../routing/timeboundary/TimeBoundaryManager.java  |  77 +++++++++---
 .../SegmentPartitionMetadataManagerTest.java       | 133 +++++++++++++++++++++
 .../SegmentPartitionUtilsTest.java                 | 108 +++++++++++++++++
 .../timeboundary/TimeBoundaryManagerTest.java      | 121 +++++++++++++++++++
 6 files changed, 586 insertions(+), 36 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManager.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManager.java
index 1cf6ed6a465..cf41e32ee1d 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManager.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManager.java
@@ -18,6 +18,8 @@
  */
 package org.apache.pinot.broker.routing.segmentpartition;
 
+import com.google.common.annotations.VisibleForTesting;
+import java.time.Clock;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -63,10 +65,18 @@ public class SegmentPartitionMetadataManager implements 
SegmentZkMetadataFetchLi
   private final String _partitionFunctionName;
   private final int _numPartitions;
   private final long _newSegmentExpirationMs;
+  private final Clock _clock;
 
   // cache-able content, only follow changes if onlineSegments list (of 
ideal-state) is changed.
   private final Map<String, SegmentInfo> _segmentInfoMap = new HashMap<>();
 
+  // Among the segments treated as new by the last 
computeTablePartitionReplicatedServersInfo(), the last time at which
+  // the first of them to expire is still new, or Long.MAX_VALUE when there is 
no new segment.
+  // Newness is re-evaluated against the clock only when the partition info is 
recomputed, so an assignment change that
+  // does not otherwise change anything must still recompute once this time 
has passed.
+  // Writer-private: only read and written by the rebuild thread.
+  private long _nextNewSegmentExpirationTimeMs = Long.MAX_VALUE;
+
   // computed value based on status change.
   // NOTE: Volatile because they are written while the table's routing entry 
is built or updated, and read without any
   // lock by unrelated threads (e.g. query planner threads). The writers are 
serialized by BaseBrokerRoutingManager,
@@ -78,22 +88,31 @@ public class SegmentPartitionMetadataManager implements 
SegmentZkMetadataFetchLi
 
   public SegmentPartitionMetadataManager(String tableNameWithType, String 
partitionColumn, String partitionFunctionName,
       int numPartitions, long newSegmentExpirationMs) {
+    this(tableNameWithType, partitionColumn, partitionFunctionName, 
numPartitions, newSegmentExpirationMs,
+        Clock.systemUTC());
+  }
+
+  @VisibleForTesting
+  SegmentPartitionMetadataManager(String tableNameWithType, String 
partitionColumn, String partitionFunctionName,
+      int numPartitions, long newSegmentExpirationMs, Clock clock) {
     _tableNameWithType = tableNameWithType;
     _partitionColumn = partitionColumn;
     _partitionFunctionName = partitionFunctionName;
     _numPartitions = numPartitions;
     _newSegmentExpirationMs = newSegmentExpirationMs;
+    _clock = clock;
   }
 
   @Override
   public void init(IdealState idealState, ExternalView externalView, 
List<String> onlineSegments,
       List<ZNRecord> znRecords) {
+    Map<List<String>, List<String>> onlineServersCache = new HashMap<>();
     int numSegments = onlineSegments.size();
     for (int i = 0; i < numSegments; i++) {
       String segment = onlineSegments.get(i);
       ZNRecord znRecord = znRecords.get(i);
       SegmentInfo segmentInfo = new SegmentInfo(getPartitionId(segment, 
znRecord), getCreationTimeMs(znRecord),
-          getOnlineServers(externalView, segment));
+          getOnlineServers(externalView.getStateMap(segment), 
onlineServersCache));
       _segmentInfoMap.put(segment, segmentInfo);
     }
     computeAllTablePartitionInfo();
@@ -129,49 +148,104 @@ public class SegmentPartitionMetadataManager implements 
SegmentZkMetadataFetchLi
     return SegmentUtils.getSegmentCreationTimeMs(new 
SegmentZKMetadata(znRecord));
   }
 
-  private static List<String> getOnlineServers(ExternalView externalView, 
String segment) {
-    Map<String, String> instanceStateMap = externalView.getStateMap(segment);
+  /// Returns the immutable list of servers hosting the segment in ONLINE or 
CONSUMING state.
+  ///
+  /// Equal lists are shared through `onlineServersCache`, so that the many 
segments hosted on the same servers (e.g.
+  /// with replica-group assignment) keep a single list instead of one per 
segment.
+  private static List<String> getOnlineServers(@Nullable Map<String, String> 
instanceStateMap,
+      Map<List<String>, List<String>> onlineServersCache) {
     if (instanceStateMap == null) {
       return List.of();
     }
     List<String> onlineServers = new ArrayList<>(instanceStateMap.size());
     for (Map.Entry<String, String> entry : instanceStateMap.entrySet()) {
-      String instanceState = entry.getValue();
-      if (instanceState.equals(SegmentStateModel.ONLINE) || 
instanceState.equals(SegmentStateModel.CONSUMING)) {
+      if (isOnline(entry.getValue())) {
         onlineServers.add(entry.getKey());
       }
     }
-    return onlineServers;
+    if (onlineServers.isEmpty()) {
+      return List.of();
+    }
+    return onlineServersCache.computeIfAbsent(onlineServers, 
Collections::unmodifiableList);
+  }
+
+  private static boolean isOnline(String instanceState) {
+    return instanceState.equals(SegmentStateModel.ONLINE) || 
instanceState.equals(SegmentStateModel.CONSUMING);
+  }
+
+  /// Returns whether `onlineServers` holds exactly the servers in ONLINE or 
CONSUMING state in `instanceStateMap`.
+  /// Does not allocate, so that the common case of an unchanged segment costs 
no garbage.
+  private static boolean hasSameOnlineServers(List<String> onlineServers,
+      @Nullable Map<String, String> instanceStateMap) {
+    if (instanceStateMap == null) {
+      return onlineServers.isEmpty();
+    }
+    int numOnlineServers = 0;
+    for (Map.Entry<String, String> entry : instanceStateMap.entrySet()) {
+      if (isOnline(entry.getValue())) {
+        // NOTE: The list holds the keys of a map, so it has no duplicates and 
a size check is enough after this.
+        if (!onlineServers.contains(entry.getKey())) {
+          return false;
+        }
+        numOnlineServers++;
+      }
+    }
+    return numOnlineServers == onlineServers.size();
   }
 
   @Override
   public synchronized void onAssignmentChange(IdealState idealState, 
ExternalView externalView,
       Set<String> onlineSegments, List<String> pulledSegments, List<ZNRecord> 
znRecords) {
+    // Both published objects are recomputed only when their inputs change: 
TablePartitionInfo depends on the segments
+    // and their partition ids, TablePartitionReplicatedServersInfo also 
depends on the online servers of each segment
+    // and on the clock (see _nextNewSegmentExpirationTimeMs).
+    boolean partitionsChanged = false;
+    boolean onlineServersChanged = false;
+    Map<List<String>, List<String>> onlineServersCache = new HashMap<>();
     // Update segment partition id for the pulled segments
+    // NOTE: Segments without ZK metadata are pulled again on every change, so 
compare with the existing info.
     int numSegments = pulledSegments.size();
     for (int i = 0; i < numSegments; i++) {
       String segment = pulledSegments.get(i);
       ZNRecord znRecord = znRecords.get(i);
-      SegmentInfo segmentInfo = new SegmentInfo(getPartitionId(segment, 
znRecord), getCreationTimeMs(znRecord),
-          getOnlineServers(externalView, segment));
-      _segmentInfoMap.put(segment, segmentInfo);
+      int partitionId = getPartitionId(segment, znRecord);
+      long creationTimeMs = getCreationTimeMs(znRecord);
+      SegmentInfo segmentInfo = _segmentInfoMap.get(segment);
+      if (segmentInfo == null) {
+        _segmentInfoMap.put(segment, new SegmentInfo(partitionId, 
creationTimeMs,
+            getOnlineServers(externalView.getStateMap(segment), 
onlineServersCache)));
+        partitionsChanged = true;
+      } else if (segmentInfo._partitionId != partitionId || 
segmentInfo._creationTimeMs != creationTimeMs) {
+        segmentInfo._partitionId = partitionId;
+        segmentInfo._creationTimeMs = creationTimeMs;
+        partitionsChanged = true;
+      }
     }
     // Update online servers for all online segments
     for (String segment : onlineSegments) {
+      Map<String, String> instanceStateMap = externalView.getStateMap(segment);
       SegmentInfo segmentInfo = _segmentInfoMap.get(segment);
       if (segmentInfo == null) {
         // NOTE: This should not happen, but we still handle it gracefully by 
adding an invalid SegmentInfo
         LOGGER.error("Failed to find segment info for segment: {} in table: {} 
while handling assignment change",
             segment, _tableNameWithType);
-        segmentInfo =
-            new SegmentInfo(INVALID_PARTITION_ID, INVALID_CREATION_TIME_MS, 
getOnlineServers(externalView, segment));
+        segmentInfo = new SegmentInfo(INVALID_PARTITION_ID, 
INVALID_CREATION_TIME_MS,
+            getOnlineServers(instanceStateMap, onlineServersCache));
         _segmentInfoMap.put(segment, segmentInfo);
-      } else {
-        segmentInfo._onlineServers = getOnlineServers(externalView, segment);
+        partitionsChanged = true;
+      } else if (!hasSameOnlineServers(segmentInfo._onlineServers, 
instanceStateMap)) {
+        segmentInfo._onlineServers = getOnlineServers(instanceStateMap, 
onlineServersCache);
+        onlineServersChanged = true;
       }
     }
-    _segmentInfoMap.keySet().retainAll(onlineSegments);
-    computeAllTablePartitionInfo();
+    if (_segmentInfoMap.keySet().retainAll(onlineSegments)) {
+      partitionsChanged = true;
+    }
+    if (partitionsChanged) {
+      computeAllTablePartitionInfo();
+    } else if (onlineServersChanged || _clock.millis() > 
_nextNewSegmentExpirationTimeMs) {
+      computeTablePartitionReplicatedServersInfo();
+    }
   }
 
   @Override
@@ -211,7 +285,8 @@ public class SegmentPartitionMetadataManager implements 
SegmentZkMetadataFetchLi
     List<Pair<String, Integer>> unavailableSegments = new ArrayList<>();
     List<Triple<String, Integer, Integer>> 
segmentsReducingFullyReplicatedServers = new ArrayList<>();
     List<Map.Entry<String, SegmentInfo>> newSegmentInfoEntries = new 
ArrayList<>();
-    long currentTimeMs = System.currentTimeMillis();
+    long currentTimeMs = _clock.millis();
+    long nextNewSegmentExpirationTimeMs = Long.MAX_VALUE;
     for (Map.Entry<String, SegmentInfo> entry : _segmentInfoMap.entrySet()) {
       String segment = entry.getKey();
       SegmentInfo segmentInfo = entry.getValue();
@@ -223,6 +298,8 @@ public class SegmentPartitionMetadataManager implements 
SegmentZkMetadataFetchLi
       // Process new segments in the end
       if (InstanceSelector.isNewSegment(segmentInfo._creationTimeMs, 
currentTimeMs, _newSegmentExpirationMs)) {
         newSegmentInfoEntries.add(entry);
+        nextNewSegmentExpirationTimeMs =
+            Math.min(nextNewSegmentExpirationTimeMs, 
getNewSegmentExpirationTimeMs(segmentInfo._creationTimeMs));
         continue;
       }
       List<String> onlineServers = segmentInfo._onlineServers;
@@ -333,11 +410,19 @@ public class SegmentPartitionMetadataManager implements 
SegmentZkMetadataFetchLi
             _tableNameWithType);
       }
     }
+    _nextNewSegmentExpirationTimeMs = nextNewSegmentExpirationTimeMs;
     _tablePartitionReplicatedServersInfo =
         new TablePartitionReplicatedServersInfo(_tableNameWithType, 
_partitionColumn, _partitionFunctionName,
             _numPartitions, partitionInfoMap, segmentsWithInvalidPartition, 
partitionsWithOnlyDeferredSegments);
   }
 
+  /// Returns the time after which a segment created at `creationTimeMs` is no 
longer new, i.e. the last time at which
+  /// [InstanceSelector#isNewSegment] still returns `true` for it. Saturates 
instead of overflowing.
+  private long getNewSegmentExpirationTimeMs(long creationTimeMs) {
+    return creationTimeMs > Long.MAX_VALUE - _newSegmentExpirationMs ? 
Long.MAX_VALUE
+        : creationTimeMs + _newSegmentExpirationMs;
+  }
+
   private void computeTablePartitionInfo() {
     List<List<String>> segmentsByPartition = new ArrayList<>();
     for (int i = 0; i < _numPartitions; i++) {
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtils.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtils.java
index 5821b498815..9a8e359f5a3 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtils.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtils.java
@@ -18,12 +18,16 @@
  */
 package org.apache.pinot.broker.routing.segmentpartition;
 
+import it.unimi.dsi.fastutil.ints.IntSet;
+import it.unimi.dsi.fastutil.ints.IntSets;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import javax.annotation.Nullable;
 import org.apache.helix.zookeeper.datamodel.ZNRecord;
 import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
+import org.apache.pinot.segment.spi.partition.PartitionFunction;
 import org.apache.pinot.segment.spi.partition.PartitionFunctionFactory;
 import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
 import org.apache.pinot.spi.utils.CommonConstants;
@@ -40,6 +44,26 @@ public class SegmentPartitionUtils {
 
   private static final Logger LOGGER = 
LoggerFactory.getLogger(SegmentPartitionUtils.class);
 
+  // Shared partition functions, keyed by the metadata they are built from. 
Every segment of a table usually carries the
+  // same partition function metadata, so sharing one instance avoids creating 
one per segment on every routing
+  // rebuild. Sharing is safe because PartitionFunction implementations must 
be stateless and thread-safe (see the
+  // PartitionFunction contract). The cache stops growing at 
MAX_CACHED_PARTITION_FUNCTIONS distinct entries, after
+  // which new functions are created without being cached.
+  private static final int MAX_CACHED_PARTITION_FUNCTIONS = 1024;
+  private static final Map<PartitionFunctionKey, PartitionFunction> 
PARTITION_FUNCTION_CACHE =
+      new ConcurrentHashMap<>();
+
+  // Shared immutable one-element partition sets. Most segments hold a single 
partition, so sharing them avoids one set
+  // per segment.
+  private static final int NUM_CACHED_SINGLE_PARTITION_SETS = 1024;
+  private static final IntSet[] SINGLE_PARTITION_SETS = new 
IntSet[NUM_CACHED_SINGLE_PARTITION_SETS];
+
+  static {
+    for (int i = 0; i < NUM_CACHED_SINGLE_PARTITION_SETS; i++) {
+      SINGLE_PARTITION_SETS[i] = IntSets.singleton(i);
+    }
+  }
+
   /// Returns the partition info for a given segment with single partition 
column.
   ///
   /// NOTE: Returns `null` when the ZNRecord is missing (could be transient 
Helix issue). Returns
@@ -76,9 +100,42 @@ public class SegmentPartitionUtils {
       return INVALID_PARTITION_INFO;
     }
 
-    return new SegmentPartitionInfo(partitionColumn,
-        PartitionFunctionFactory.getPartitionFunction(columnPartitionMetadata),
-        columnPartitionMetadata.getPartitions());
+    return new SegmentPartitionInfo(partitionColumn, 
getPartitionFunction(columnPartitionMetadata),
+        getPartitions(columnPartitionMetadata));
+  }
+
+  /// Returns a partition function for the given metadata, shared with the 
other segments with equal metadata.
+  private static PartitionFunction 
getPartitionFunction(ColumnPartitionMetadata columnPartitionMetadata) {
+    PartitionFunctionKey key = new 
PartitionFunctionKey(columnPartitionMetadata.getFunctionName(),
+        columnPartitionMetadata.getNumPartitions(), 
columnPartitionMetadata.getFunctionConfig());
+    PartitionFunction partitionFunction = PARTITION_FUNCTION_CACHE.get(key);
+    if (partitionFunction != null) {
+      return partitionFunction;
+    }
+    partitionFunction = 
PartitionFunctionFactory.getPartitionFunction(columnPartitionMetadata);
+    if (PARTITION_FUNCTION_CACHE.size() < MAX_CACHED_PARTITION_FUNCTIONS) {
+      PartitionFunction existing = PARTITION_FUNCTION_CACHE.putIfAbsent(key, 
partitionFunction);
+      if (existing != null) {
+        return existing;
+      }
+    }
+    return partitionFunction;
+  }
+
+  /// Returns the partitions of the given metadata, replacing a one-element 
set with a shared immutable one.
+  private static Set<Integer> getPartitions(ColumnPartitionMetadata 
columnPartitionMetadata) {
+    Set<Integer> partitions = columnPartitionMetadata.getPartitions();
+    if (partitions.size() == 1) {
+      int partition = partitions.iterator().next();
+      if (partition >= 0 && partition < NUM_CACHED_SINGLE_PARTITION_SETS) {
+        return SINGLE_PARTITION_SETS[partition];
+      }
+    }
+    return partitions;
+  }
+
+  private record PartitionFunctionKey(String functionName, int numPartitions,
+                                      @Nullable Map<String, String> 
functionConfig) {
   }
 
   /// Returns a map from partition column name to partition info for a given 
segment with multiple partition columns.
@@ -119,8 +176,7 @@ public class SegmentPartitionUtils {
         continue;
       }
       SegmentPartitionInfo segmentPartitionInfo = new 
SegmentPartitionInfo(partitionColumn,
-          
PartitionFunctionFactory.getPartitionFunction(columnPartitionMetadata),
-          columnPartitionMetadata.getPartitions());
+          getPartitionFunction(columnPartitionMetadata), 
getPartitions(columnPartitionMetadata));
       columnSegmentPartitionInfoMap.put(partitionColumn, segmentPartitionInfo);
     }
     if (columnSegmentPartitionInfoMap.size() == 1) {
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManager.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManager.java
index 18256275af9..81396eab437 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManager.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManager.java
@@ -21,6 +21,7 @@ package org.apache.pinot.broker.routing.timeboundary;
 import com.google.common.base.Preconditions;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -63,6 +64,9 @@ public class TimeBoundaryManager {
   private final DateTimeFormatSpec _timeFormatSpec;
   private final long _timeOffsetMs;
   private final Map<String, Long> _endTimeMsMap = new HashMap<>();
+  // Max value in _endTimeMsMap, or INVALID_TIME_MS when it is empty. Kept in 
sync with the map so that an assignment
+  // change does not scan all the segments.
+  private long _maxEndTimeMs = INVALID_TIME_MS;
 
   private long _explicitlySetTimeBoundaryMs = INVALID_TIME_MS;
   private volatile TimeBoundaryInfo _timeBoundaryInfo;
@@ -120,14 +124,12 @@ public class TimeBoundaryManager {
       segmentZKMetadataPaths.add(_segmentZKMetadataPathPrefix + segment);
     }
     List<ZNRecord> znRecords = _propertyStore.get(segmentZKMetadataPaths, 
null, AccessOption.PERSISTENT, false);
-    long maxEndTimeMs = INVALID_TIME_MS;
     for (int i = 0; i < numSegments; i++) {
       String segment = segments.get(i);
-      long endTimeMs = extractEndTimeMsFromSegmentZKMetadataZNRecord(segment, 
znRecords.get(i));
-      _endTimeMsMap.put(segment, endTimeMs);
-      maxEndTimeMs = Math.max(maxEndTimeMs, endTimeMs);
+      _endTimeMsMap.put(segment, 
extractEndTimeMsFromSegmentZKMetadataZNRecord(segment, znRecords.get(i)));
     }
-    updateTimeBoundaryInfo(maxEndTimeMs);
+    _maxEndTimeMs = computeMaxEndTimeMs();
+    updateTimeBoundaryInfo(_maxEndTimeMs);
   }
 
   private void updateExplicitlySetTimeBoundary(IdealState idealState) {
@@ -206,20 +208,58 @@ public class TimeBoundaryManager {
       Set<String> onlineSegments) {
     updateExplicitlySetTimeBoundary(idealState);
 
+    // Read the ZK metadata of the new segments in one batch
+    List<String> newSegments = new ArrayList<>();
+    List<String> newSegmentZKMetadataPaths = new ArrayList<>();
     for (String segment : onlineSegments) {
       // NOTE: Only update the segment end time when there are ONLINE 
instances in the external view to prevent moving
       //       the time boundary before the new segment is picked up by the 
servers
-      Map<String, String> instanceStateMap = externalView.getStateMap(segment);
-      if (instanceStateMap != null && 
instanceStateMap.containsValue(SegmentStateModel.ONLINE)) {
-        _endTimeMsMap.computeIfAbsent(segment, k -> 
extractEndTimeMsFromSegmentZKMetadataZNRecord(segment,
-            _propertyStore.get(_segmentZKMetadataPathPrefix + segment, null, 
AccessOption.PERSISTENT)));
+      if (!_endTimeMsMap.containsKey(segment)) {
+        Map<String, String> instanceStateMap = 
externalView.getStateMap(segment);
+        if (instanceStateMap != null && 
instanceStateMap.containsValue(SegmentStateModel.ONLINE)) {
+          newSegments.add(segment);
+          newSegmentZKMetadataPaths.add(_segmentZKMetadataPathPrefix + 
segment);
+        }
+      }
+    }
+    // NOTE: Read before changing any state, so that a failed read leaves the 
state as it was. The read throws on a ZK
+    //       error other than a missing node, so that the routing entry keeps 
its IS/EV versions and retries.
+    List<ZNRecord> znRecords = newSegments.isEmpty() ? List.of()
+        : _propertyStore.get(newSegmentZKMetadataPaths, null, 
AccessOption.PERSISTENT, true);
+    boolean recomputeMaxEndTime = false;
+    // NOTE: Removing a segment can only lower the max end time, so a full 
scan is needed only when a removed segment
+    //       holds the current max end time.
+    Iterator<Map.Entry<String, Long>> iterator = 
_endTimeMsMap.entrySet().iterator();
+    while (iterator.hasNext()) {
+      Map.Entry<String, Long> entry = iterator.next();
+      if (!onlineSegments.contains(entry.getKey())) {
+        if (entry.getValue() >= _maxEndTimeMs) {
+          recomputeMaxEndTime = true;
+        }
+        iterator.remove();
+      }
+    }
+    if (recomputeMaxEndTime) {
+      _maxEndTimeMs = computeMaxEndTimeMs();
+    }
+    int numNewSegments = newSegments.size();
+    for (int i = 0; i < numNewSegments; i++) {
+      String segment = newSegments.get(i);
+      ZNRecord znRecord = znRecords.get(i);
+      if (znRecord == null) {
+        // NOTE: The node does not exist. Do not record the segment, so that 
the next assignment change reads it again.
+        LOGGER.warn("Failed to find segment ZK metadata for segment: {}, 
table: {}, will retry on next change", segment,
+            _offlineTableName);
+        continue;
       }
+      long endTimeMs = extractEndTimeMsFromSegmentZKMetadataZNRecord(segment, 
znRecord);
+      _endTimeMsMap.put(segment, endTimeMs);
+      _maxEndTimeMs = Math.max(_maxEndTimeMs, endTimeMs);
     }
-    _endTimeMsMap.keySet().retainAll(onlineSegments);
-    updateTimeBoundaryInfo(getMaxEndTimeMs());
+    updateTimeBoundaryInfo(_maxEndTimeMs);
   }
 
-  private long getMaxEndTimeMs() {
+  private long computeMaxEndTimeMs() {
     long maxEndTimeMs = INVALID_TIME_MS;
     for (long endTimeMs : _endTimeMsMap.values()) {
       maxEndTimeMs = Math.max(maxEndTimeMs, endTimeMs);
@@ -229,9 +269,16 @@ public class TimeBoundaryManager {
 
   /// Refreshes the metadata for the given segment (called when segment is 
getting refreshed).
   public synchronized void refreshSegment(String segment) {
-    _endTimeMsMap.put(segment, 
extractEndTimeMsFromSegmentZKMetadataZNRecord(segment,
-        _propertyStore.get(_segmentZKMetadataPathPrefix + segment, null, 
AccessOption.PERSISTENT)));
-    updateTimeBoundaryInfo(getMaxEndTimeMs());
+    long endTimeMs = extractEndTimeMsFromSegmentZKMetadataZNRecord(segment,
+        _propertyStore.get(_segmentZKMetadataPathPrefix + segment, null, 
AccessOption.PERSISTENT));
+    Long previousEndTimeMs = _endTimeMsMap.put(segment, endTimeMs);
+    if (previousEndTimeMs != null && previousEndTimeMs >= _maxEndTimeMs && 
endTimeMs < previousEndTimeMs) {
+      // The refreshed segment held the max end time and moved it back
+      _maxEndTimeMs = computeMaxEndTimeMs();
+    } else {
+      _maxEndTimeMs = Math.max(_maxEndTimeMs, endTimeMs);
+    }
+    updateTimeBoundaryInfo(_maxEndTimeMs);
   }
 
   @Nullable
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManagerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManagerTest.java
index 9901b71c96e..5c6d6af0cbb 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManagerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManagerTest.java
@@ -19,6 +19,9 @@
 package org.apache.pinot.broker.routing.segmentpartition;
 
 import com.google.common.collect.ImmutableSet;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -36,13 +39,17 @@ import 
org.apache.pinot.broker.routing.segmentmetadata.SegmentZkMetadataFetcher;
 import org.apache.pinot.common.metadata.ZKMetadataProvider;
 import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
 import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.common.utils.TestClock;
 import org.apache.pinot.controller.helix.ControllerTest;
+import org.apache.pinot.core.routing.TablePartitionInfo;
 import org.apache.pinot.core.routing.TablePartitionReplicatedServersInfo;
 import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
 import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
+import static 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel.CONSUMING;
+import static 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel.OFFLINE;
 import static 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel.ONLINE;
 import static org.testng.Assert.*;
 
@@ -431,6 +438,132 @@ public class SegmentPartitionMetadataManagerTest extends 
ControllerTest {
             + 
tablePartitionReplicatedServersInfo.getPartitionsWithOnlyDeferredSegments());
   }
 
+  /// An assignment change that changes neither the segments, their partitions 
nor their online servers must keep the
+  /// published objects, while a change of the online servers must only 
replace the replicated servers info.
+  @Test
+  public void testRecomputeOnlyOnChange() {
+    ExternalView externalView = new ExternalView(OFFLINE_TABLE_NAME);
+    Map<String, Map<String, String>> segmentAssignment = 
externalView.getRecord().getMapFields();
+    Set<String> onlineSegments = new HashSet<>();
+    // NOTE: Ideal state is not used in the current implementation.
+    IdealState idealState = new IdealState(OFFLINE_TABLE_NAME);
+
+    SegmentPartitionMetadataManager partitionMetadataManager =
+        new SegmentPartitionMetadataManager(OFFLINE_TABLE_NAME, 
PARTITION_COLUMN, PARTITION_COLUMN_FUNC, NUM_PARTITIONS,
+            TimeUnit.MINUTES.toMillis(5));
+    SegmentZkMetadataFetcher segmentZkMetadataFetcher =
+        new SegmentZkMetadataFetcher(OFFLINE_TABLE_NAME, _propertyStore);
+    segmentZkMetadataFetcher.register(partitionMetadataManager);
+
+    String segment0 = "recomputeSegment0";
+    String segment1 = "recomputeSegment1";
+    String segmentWithoutMetadata = "recomputeSegmentWithoutMetadata";
+    onlineSegments.add(segment0);
+    onlineSegments.add(segment1);
+    segmentAssignment.put(segment0, Map.of(SERVER_0, ONLINE, SERVER_1, 
ONLINE));
+    segmentAssignment.put(segment1, Map.of(SERVER_0, ONLINE, SERVER_1, 
ONLINE));
+    setSegmentZKMetadata(segment0, PARTITION_COLUMN_FUNC, NUM_PARTITIONS, 0, 
0L);
+    setSegmentZKMetadata(segment1, PARTITION_COLUMN_FUNC, NUM_PARTITIONS, 1, 
0L);
+    segmentZkMetadataFetcher.init(idealState, externalView, onlineSegments);
+    // A segment without ZK metadata is pulled again on every change, which 
alone must not count as a change
+    onlineSegments.add(segmentWithoutMetadata);
+    segmentAssignment.put(segmentWithoutMetadata, Map.of(SERVER_0, ONLINE));
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    TablePartitionInfo tablePartitionInfo = 
partitionMetadataManager.getTablePartitionInfo();
+    TablePartitionReplicatedServersInfo replicatedServersInfo =
+        partitionMetadataManager.getTablePartitionReplicatedServersInfo();
+    assertEquals(replicatedServersInfo.getSegmentsWithInvalidPartition(), 
List.of(segmentWithoutMetadata));
+
+    // No-op change, with a fresh but equal instance state map and an 
unrelated state change in the external view
+    segmentAssignment.put(segment0, new HashMap<>(Map.of(SERVER_0, ONLINE, 
SERVER_1, ONLINE)));
+    segmentAssignment.put("segmentNotOnline", Map.of(SERVER_0, ONLINE));
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    assertSame(partitionMetadataManager.getTablePartitionInfo(), 
tablePartitionInfo);
+    
assertSame(partitionMetadataManager.getTablePartitionReplicatedServersInfo(), 
replicatedServersInfo);
+
+    // An OFFLINE replica is not an online server
+    segmentAssignment.put(segment1, Map.of(SERVER_0, ONLINE, SERVER_1, 
OFFLINE));
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    assertSame(partitionMetadataManager.getTablePartitionInfo(), 
tablePartitionInfo);
+    replicatedServersInfo = 
partitionMetadataManager.getTablePartitionReplicatedServersInfo();
+    TablePartitionReplicatedServersInfo.PartitionInfo[] partitionInfoMap = 
replicatedServersInfo.getPartitionInfoMap();
+    assertEquals(partitionInfoMap[0]._fullyReplicatedServers, Set.of(SERVER_0, 
SERVER_1));
+    assertEquals(partitionInfoMap[1]._fullyReplicatedServers, 
Set.of(SERVER_0));
+
+    // Same online servers with a different state (CONSUMING instead of 
ONLINE) is a no-op
+    segmentAssignment.put(segment1, Map.of(SERVER_0, CONSUMING, SERVER_1, 
OFFLINE));
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    assertSame(partitionMetadataManager.getTablePartitionInfo(), 
tablePartitionInfo);
+    
assertSame(partitionMetadataManager.getTablePartitionReplicatedServersInfo(), 
replicatedServersInfo);
+
+    // Removing a segment replaces both objects
+    onlineSegments.remove(segment1);
+    segmentAssignment.remove(segment1);
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    assertNotSame(partitionMetadataManager.getTablePartitionInfo(), 
tablePartitionInfo);
+    
assertNotSame(partitionMetadataManager.getTablePartitionReplicatedServersInfo(),
 replicatedServersInfo);
+    
assertEquals(partitionMetadataManager.getTablePartitionInfo().getSegmentsByPartition(),
+        List.of(List.of(segment0), List.of()));
+    
assertNull(partitionMetadataManager.getTablePartitionReplicatedServersInfo().getPartitionInfoMap()[1]);
+  }
+
+  /// The partition info re-evaluates which segments are new only when it is 
recomputed, so an assignment change that
+  /// changes nothing else must still recompute once a new segment excluded 
from the partition info is no longer new.
+  @Test
+  public void testNewSegmentExpiresOnNoOpChange() {
+    ExternalView externalView = new ExternalView(OFFLINE_TABLE_NAME);
+    Map<String, Map<String, String>> segmentAssignment = 
externalView.getRecord().getMapFields();
+    Set<String> onlineSegments = new HashSet<>();
+    // NOTE: Ideal state is not used in the current implementation.
+    IdealState idealState = new IdealState(OFFLINE_TABLE_NAME);
+
+    long newSegmentExpirationMs = TimeUnit.MINUTES.toMillis(5);
+    TestClock clock = new TestClock(Instant.now(), ZoneId.systemDefault());
+    SegmentPartitionMetadataManager partitionMetadataManager =
+        new SegmentPartitionMetadataManager(OFFLINE_TABLE_NAME, 
PARTITION_COLUMN, PARTITION_COLUMN_FUNC, NUM_PARTITIONS,
+            newSegmentExpirationMs, clock);
+    SegmentZkMetadataFetcher segmentZkMetadataFetcher =
+        new SegmentZkMetadataFetcher(OFFLINE_TABLE_NAME, _propertyStore);
+    segmentZkMetadataFetcher.register(partitionMetadataManager);
+
+    String oldSegment = "expiryOldSegment";
+    onlineSegments.add(oldSegment);
+    segmentAssignment.put(oldSegment, Map.of(SERVER_0, ONLINE, SERVER_1, 
ONLINE));
+    setSegmentZKMetadata(oldSegment, PARTITION_COLUMN_FUNC, NUM_PARTITIONS, 0, 
0L);
+    segmentZkMetadataFetcher.init(idealState, externalView, onlineSegments);
+
+    // A new segment missing one replica is excluded from the partition info. 
It stops being new 1 second from now.
+    String newSegment = "expiryNewSegment";
+    long creationTimeMs = clock.millis() - newSegmentExpirationMs + 1000;
+    onlineSegments.add(newSegment);
+    segmentAssignment.put(newSegment, Map.of(SERVER_0, ONLINE));
+    setSegmentZKMetadata(newSegment, PARTITION_COLUMN_FUNC, NUM_PARTITIONS, 0, 
creationTimeMs);
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    TablePartitionReplicatedServersInfo replicatedServersInfo =
+        partitionMetadataManager.getTablePartitionReplicatedServersInfo();
+    TablePartitionReplicatedServersInfo.PartitionInfo partitionInfo = 
replicatedServersInfo.getPartitionInfoMap()[0];
+    assertEquals(partitionInfo._segments, List.of(oldSegment));
+    assertEquals(partitionInfo._fullyReplicatedServers, Set.of(SERVER_0, 
SERVER_1));
+
+    // While the segment is still new, a no-op change keeps the published 
object
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    
assertSame(partitionMetadataManager.getTablePartitionReplicatedServersInfo(), 
replicatedServersInfo);
+
+    // Move the clock past the expiration of the segment
+    clock.fastForward(Duration.ofSeconds(2));
+
+    // A no-op change must now include the segment, as a regular segment 
reducing the fully replicated servers
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    replicatedServersInfo = 
partitionMetadataManager.getTablePartitionReplicatedServersInfo();
+    partitionInfo = replicatedServersInfo.getPartitionInfoMap()[0];
+    assertEqualsNoOrder(partitionInfo._segments.toArray(), new 
String[]{oldSegment, newSegment});
+    assertEquals(partitionInfo._fullyReplicatedServers, Set.of(SERVER_0));
+
+    // Once no segment is new, a no-op change keeps the published object
+    segmentZkMetadataFetcher.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    
assertSame(partitionMetadataManager.getTablePartitionReplicatedServersInfo(), 
replicatedServersInfo);
+  }
+
   private void setSegmentZKMetadata(String segment, String partitionFunction, 
int numPartitions, int partitionId,
       long creationTimeMs) {
     SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(segment);
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtilsTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtilsTest.java
new file mode 100644
index 00000000000..9dd3a1130be
--- /dev/null
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtilsTest.java
@@ -0,0 +1,108 @@
+/**
+ * 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.broker.routing.segmentpartition;
+
+import java.util.Map;
+import java.util.Set;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.pinot.common.metadata.segment.SegmentPartitionMetadata;
+import org.apache.pinot.common.metadata.segment.SegmentZKMetadata;
+import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+
+
+/// Tests for [SegmentPartitionUtils].
+public class SegmentPartitionUtilsTest {
+  private static final String TABLE_NAME = "testTable_OFFLINE";
+  private static final String PARTITION_COLUMN = "memberId";
+  private static final String OTHER_PARTITION_COLUMN = "groupId";
+
+  /// Segments with equal partition function metadata share one partition 
function, and one-element partition sets
+  /// are shared too.
+  @Test
+  public void testSharedPartitionFunctionsAndPartitionSets() {
+    SegmentPartitionInfo info0 = extract("segment0", "Murmur", 8, Set.of(3), 
null);
+    SegmentPartitionInfo info1 = extract("segment1", "Murmur", 8, Set.of(3), 
null);
+    SegmentPartitionInfo info2 = extract("segment2", "Murmur", 8, Set.of(5), 
null);
+    assertSame(info1.getPartitionFunction(), info0.getPartitionFunction());
+    assertSame(info2.getPartitionFunction(), info0.getPartitionFunction());
+    assertSame(info1.getPartitions(), info0.getPartitions());
+    assertEquals(info0.getPartitions(), Set.of(3));
+    assertEquals(info2.getPartitions(), Set.of(5));
+    assertEquals(info0.getPartitionFunction().getName(), "Murmur");
+    assertEquals(info0.getPartitionFunction().getNumPartitions(), 8);
+
+    // A different number of partitions, function or function config is a 
different function
+    SegmentPartitionInfo otherNumPartitions = extract("segment3", "Murmur", 
16, Set.of(3), null);
+    assertNotSame(otherNumPartitions.getPartitionFunction(), 
info0.getPartitionFunction());
+    assertEquals(otherNumPartitions.getPartitionFunction().getNumPartitions(), 
16);
+    SegmentPartitionInfo otherFunction = extract("segment4", "Modulo", 8, 
Set.of(3), null);
+    assertNotSame(otherFunction.getPartitionFunction(), 
info0.getPartitionFunction());
+    assertEquals(otherFunction.getPartitionFunction().getName(), "Modulo");
+    SegmentPartitionInfo otherConfig = extract("segment5", "Murmur", 8, 
Set.of(3), Map.of("useRawBytes", "true"));
+    assertNotSame(otherConfig.getPartitionFunction(), 
info0.getPartitionFunction());
+
+    // Multi-partition sets are kept as is
+    SegmentPartitionInfo multiPartitions = extract("segment6", "Murmur", 8, 
Set.of(1, 2), null);
+    assertEquals(multiPartitions.getPartitions(), Set.of(1, 2));
+    assertSame(multiPartitions.getPartitionFunction(), 
info0.getPartitionFunction());
+  }
+
+  @Test
+  public void testSharedPartitionFunctionsInPartitionInfoMap() {
+    ZNRecord znRecord0 = znRecord("segment0", "Murmur", 8, Set.of(3), null);
+    ZNRecord znRecord1 = znRecord("segment1", "Murmur", 8, Set.of(3), null);
+    Set<String> partitionColumns = Set.of(PARTITION_COLUMN, 
OTHER_PARTITION_COLUMN);
+    Map<String, SegmentPartitionInfo> infoMap0 =
+        SegmentPartitionUtils.extractPartitionInfoMap(TABLE_NAME, 
partitionColumns, "segment0", znRecord0);
+    Map<String, SegmentPartitionInfo> infoMap1 =
+        SegmentPartitionUtils.extractPartitionInfoMap(TABLE_NAME, 
partitionColumns, "segment1", znRecord1);
+    assertNotNull(infoMap0);
+    assertNotNull(infoMap1);
+    for (String partitionColumn : partitionColumns) {
+      assertSame(infoMap1.get(partitionColumn).getPartitionFunction(),
+          infoMap0.get(partitionColumn).getPartitionFunction());
+      assertSame(infoMap1.get(partitionColumn).getPartitions(), 
infoMap0.get(partitionColumn).getPartitions());
+      assertEquals(infoMap0.get(partitionColumn).getPartitions(), Set.of(3));
+    }
+  }
+
+  private static SegmentPartitionInfo extract(String segment, String 
functionName, int numPartitions,
+      Set<Integer> partitions, Map<String, String> functionConfig) {
+    SegmentPartitionInfo info = 
SegmentPartitionUtils.extractPartitionInfo(TABLE_NAME, PARTITION_COLUMN, 
segment,
+        znRecord(segment, functionName, numPartitions, partitions, 
functionConfig));
+    assertNotNull(info);
+    return info;
+  }
+
+  private static ZNRecord znRecord(String segment, String functionName, int 
numPartitions, Set<Integer> partitions,
+      Map<String, String> functionConfig) {
+    ColumnPartitionMetadata columnPartitionMetadata =
+        new ColumnPartitionMetadata(functionName, numPartitions, partitions, 
functionConfig);
+    SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(segment);
+    segmentZKMetadata.setPartitionMetadata(new SegmentPartitionMetadata(
+        Map.of(PARTITION_COLUMN, columnPartitionMetadata, 
OTHER_PARTITION_COLUMN, columnPartitionMetadata)));
+    return segmentZKMetadata.toZNRecord();
+  }
+}
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManagerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManagerTest.java
index 694915ebcff..b61cbd89ae6 100644
--- 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManagerTest.java
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManagerTest.java
@@ -19,9 +19,11 @@
 package org.apache.pinot.broker.routing.timeboundary;
 
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import org.apache.helix.api.exceptions.HelixMetaDataAccessException;
 import org.apache.helix.manager.zk.ZkBaseDataAccessor;
 import org.apache.helix.model.ExternalView;
 import org.apache.helix.model.IdealState;
@@ -52,6 +54,7 @@ import static 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.Segmen
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertThrows;
 
 
 public class TimeBoundaryManagerTest extends ControllerTest {
@@ -226,6 +229,124 @@ public class TimeBoundaryManagerTest extends 
ControllerTest {
     verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 
expectedTimeValue);
   }
 
+  /// The ZK metadata of the segments added by an assignment change must be 
read in one batch, and removing a segment
+  /// must only move the time boundary back when that segment held the max end 
time.
+  @Test
+  public void testBatchedReadsAndIncrementalMaxEndTime() {
+    String rawTableName = "testTableBatchedReads";
+    TableConfig tableConfig = getTableConfig(rawTableName, "DAILY");
+    TimeUnit timeUnit = TimeUnit.DAYS;
+    setSchemaTimeFieldSpec(rawTableName, timeUnit);
+    ZkHelixPropertyStore<ZNRecord> propertyStore = Mockito.spy(_propertyStore);
+    TimeBoundaryManager timeBoundaryManager =
+        new TimeBoundaryManager(tableConfig, propertyStore, 
Mockito.mock(BrokerMetrics.class));
+
+    ExternalView externalView = new ExternalView(tableConfig.getTableName());
+    Map<String, Map<String, String>> segmentAssignment = 
externalView.getRecord().getMapFields();
+    Map<String, String> onlineInstanceStateMap = Map.of("server", ONLINE);
+    Set<String> onlineSegments = new HashSet<>();
+    IdealState idealState = new IdealState("");
+    String segment0 = "batchedSegment0";
+    onlineSegments.add(segment0);
+    segmentAssignment.put(segment0, onlineInstanceStateMap);
+    setSegmentZKMetadata(rawTableName, segment0, 3, timeUnit);
+    timeBoundaryManager.init(idealState, externalView, onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 2);
+
+    // Adding 2 segments reads their ZK metadata in one batch
+    String segment1 = "batchedSegment1";
+    String segment2 = "batchedSegment2";
+    setSegmentZKMetadata(rawTableName, segment1, 4, timeUnit);
+    setSegmentZKMetadata(rawTableName, segment2, 6, timeUnit);
+    onlineSegments.add(segment1);
+    onlineSegments.add(segment2);
+    segmentAssignment.put(segment1, onlineInstanceStateMap);
+    segmentAssignment.put(segment2, onlineInstanceStateMap);
+    Mockito.clearInvocations(propertyStore);
+    timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 5);
+    Mockito.verify(propertyStore).get(Mockito.argThat((List<String> paths) -> 
paths.size() == 2), Mockito.any(),
+        Mockito.anyInt(), Mockito.anyBoolean());
+    Mockito.verifyNoMoreInteractions(propertyStore);
+
+    // An assignment change without new segment does not read ZK metadata
+    Mockito.clearInvocations(propertyStore);
+    timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 5);
+    Mockito.verifyNoInteractions(propertyStore);
+
+    // Removing a segment that does not hold the max end time keeps the time 
boundary
+    onlineSegments.remove(segment1);
+    segmentAssignment.remove(segment1);
+    timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 5);
+
+    // Removing the segment that holds the max end time moves the time 
boundary back
+    onlineSegments.remove(segment2);
+    segmentAssignment.remove(segment2);
+    timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 2);
+    Mockito.verifyNoInteractions(propertyStore);
+
+    // Refreshing the segment holding the max end time to a smaller end time 
moves the time boundary back
+    setSegmentZKMetadata(rawTableName, segment0, 2, timeUnit);
+    timeBoundaryManager.refreshSegment(segment0);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 1);
+
+    // A new segment whose ZK metadata cannot be read is read again on the 
next change
+    String segment3 = "batchedSegment3";
+    onlineSegments.add(segment3);
+    segmentAssignment.put(segment3, onlineInstanceStateMap);
+    timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 1);
+    setSegmentZKMetadata(rawTableName, segment3, 8, timeUnit);
+    timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 7);
+  }
+
+  /// A ZK read failure other than a missing node must propagate, and must 
leave the time boundary and the state as they
+  /// were, so that the next assignment change can replace the segments.
+  @Test
+  public void testReadFailureDuringReplacementKeepsTimeBoundary() {
+    String rawTableName = "testTableReadFailure";
+    TableConfig tableConfig = getTableConfig(rawTableName, "DAILY");
+    TimeUnit timeUnit = TimeUnit.DAYS;
+    setSchemaTimeFieldSpec(rawTableName, timeUnit);
+    ZkHelixPropertyStore<ZNRecord> propertyStore = Mockito.spy(_propertyStore);
+    TimeBoundaryManager timeBoundaryManager =
+        new TimeBoundaryManager(tableConfig, propertyStore, 
Mockito.mock(BrokerMetrics.class));
+
+    ExternalView externalView = new ExternalView(tableConfig.getTableName());
+    Map<String, Map<String, String>> segmentAssignment = 
externalView.getRecord().getMapFields();
+    Map<String, String> onlineInstanceStateMap = Map.of("server", ONLINE);
+    Set<String> onlineSegments = new HashSet<>();
+    IdealState idealState = new IdealState("");
+    String oldSegment = "replacedSegment";
+    onlineSegments.add(oldSegment);
+    segmentAssignment.put(oldSegment, onlineInstanceStateMap);
+    setSegmentZKMetadata(rawTableName, oldSegment, 3, timeUnit);
+    timeBoundaryManager.init(idealState, externalView, onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 2);
+
+    // Replace the old segment with a new one, and fail the read of the new 
segment ZK metadata
+    String newSegment = "replacingSegment";
+    setSegmentZKMetadata(rawTableName, newSegment, 5, timeUnit);
+    onlineSegments.remove(oldSegment);
+    segmentAssignment.remove(oldSegment);
+    onlineSegments.add(newSegment);
+    segmentAssignment.put(newSegment, onlineInstanceStateMap);
+    Mockito.doThrow(new HelixMetaDataAccessException("Failed to read node, 
return code: OPERATIONTIMEOUT"))
+        .when(propertyStore).get(Mockito.anyList(), Mockito.any(), 
Mockito.anyInt(), Mockito.eq(true));
+    assertThrows(HelixMetaDataAccessException.class,
+        () -> timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments));
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 2);
+
+    // The next assignment change reads the new segment again and replaces the 
old one
+    Mockito.reset(propertyStore);
+    timeBoundaryManager.onAssignmentChange(idealState, externalView, 
onlineSegments);
+    verifyTimeBoundaryInfo(timeBoundaryManager.getTimeBoundaryInfo(), 4);
+  }
+
   private TableConfig getTableConfig(String rawTableName, String 
pushFrequency) {
     return new 
TableConfigBuilder(TableType.OFFLINE).setTableName(rawTableName).setTimeColumnName(TIME_COLUMN)
         .setSegmentPushFrequency(pushFrequency).build();


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

Reply via email to