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 823229d0c31 Add a compact reader for table ideal states and external 
views in the broker (#19786)
823229d0c31 is described below

commit 823229d0c314e6805cee2ea0836d28843e6da3b4
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Thu Oct 15 12:11:54 2026 +0200

    Add a compact reader for table ideal states and external views in the 
broker (#19786)
    
    Part of #19782.
---
 .../broker/broker/helix/BaseBrokerStarter.java     |   3 +-
 .../helix/CompactAssignmentZkHelixManager.java     | 117 +++++
 .../helix/MultiClusterHelixBrokerStarter.java      |   7 +-
 .../routing/manager/BaseBrokerRoutingManager.java  |   6 +-
 .../helix/CompactAssignmentZkHelixManagerTest.java | 219 ++++++++
 .../CompactAssignmentInstanceSelectorTest.java     | 179 +++++++
 .../CompactAssignmentRoutingManagerTest.java       | 410 +++++++++++++++
 .../utils/helix/CompactZNRecordSerializer.java     | 468 +++++++++++++++++
 .../pinot/common/utils/helix/HelixHelper.java      |  41 ++
 .../utils/helix/ImmutableSortedArrayMap.java       | 562 +++++++++++++++++++++
 .../utils/helix/CompactZNRecordSerializerTest.java | 360 +++++++++++++
 .../pinot/common/utils/helix/HelixHelperTest.java  | 103 ++++
 .../utils/helix/ImmutableSortedArrayMapTest.java   | 207 ++++++++
 .../apache/pinot/spi/utils/CommonConstants.java    |   8 +
 14 files changed, 2683 insertions(+), 7 deletions(-)

diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
index ed117e44a81..878d08f2db8 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/BaseBrokerStarter.java
@@ -811,7 +811,8 @@ public abstract class BaseBrokerStarter implements 
ServiceStartable {
 
   protected void initSpectatorHelixManager() throws Exception {
     _spectatorHelixManager =
-      HelixManagerFactory.getZKHelixManager(_clusterName, _instanceId, 
InstanceType.SPECTATOR, _zkServers);
+        
CompactAssignmentZkHelixManager.createSpectatorHelixManager(_brokerConf, 
_clusterName, _instanceId,
+            _zkServers);
     _spectatorHelixManager.connect();
     _helixAdmin = _spectatorHelixManager.getClusterManagmentTool();
     _propertyStore = _spectatorHelixManager.getHelixPropertyStore();
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/CompactAssignmentZkHelixManager.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/CompactAssignmentZkHelixManager.java
new file mode 100644
index 00000000000..4797f892c92
--- /dev/null
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/CompactAssignmentZkHelixManager.java
@@ -0,0 +1,117 @@
+/**
+ * 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.broker.helix;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import org.apache.helix.HelixManager;
+import org.apache.helix.HelixManagerFactory;
+import org.apache.helix.InstanceType;
+import org.apache.helix.PropertyKey;
+import org.apache.helix.manager.zk.ZKHelixManager;
+import org.apache.helix.zookeeper.api.client.RealmAwareZkClient;
+import org.apache.helix.zookeeper.datamodel.serializer.ChainedPathZkSerializer;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.zkclient.serialize.PathBasedZkSerializer;
+import org.apache.pinot.common.utils.helix.CompactZNRecordSerializer;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+import org.apache.pinot.spi.utils.CommonConstants.Helix;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// A spectator [ZKHelixManager] that reads table ideal states and external 
views with
+/// [CompactZNRecordSerializer].
+///
+/// Helix creates the ZooKeeper client of the manager inside [#connect], with 
a default
+/// [ChainedPathZkSerializer] that sends every path to [ZNRecordSerializer]. 
Right after that, this class replaces the
+/// serializer of the client with one that sends `/<cluster>/IDEALSTATES/...` 
and `/<cluster>/EXTERNALVIEW/...` to
+/// [CompactZNRecordSerializer]. The broker resource keeps 
[ZNRecordSerializer]: it is small and its readers are
+/// outside of routing. The client keeps the serializer across session expiry, 
because Helix creates a new client only
+/// in [#connect], and this class installs the serializer again after each 
[#connect].
+///
+/// The serializer applies to everything that reads through this manager: its 
data accessor, its `HelixAdmin`, its
+/// property store (no path match) and the Helix callback handlers. All of 
them only read the records under those
+/// paths. Writes still go through [ZNRecordSerializer].
+///
+/// The map fields of a compact record are immutable, and the `IdealState` and 
`ExternalView` constructors share the
+/// inner instance-state maps. So every table ideal state or external view 
read through this manager, by any
+/// reader, has immutable instance-state maps. A read-modify-write of a table 
ideal state through this manager (for
+/// example `HelixHelper.updateIdealState`) fails with 
[UnsupportedOperationException]. No broker code does that; the
+/// participant manager of the broker keeps its own client and the default 
serializer.
+///
+/// Thread-safety: same as [ZKHelixManager]. The serializer is installed from 
the thread that calls [#connect],
+/// before the caller registers any listener.
+public class CompactAssignmentZkHelixManager extends ZKHelixManager {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(CompactAssignmentZkHelixManager.class);
+
+  public CompactAssignmentZkHelixManager(String clusterName, String 
instanceName, String zkAddress) {
+    super(clusterName, instanceName, InstanceType.SPECTATOR, zkAddress);
+  }
+
+  /// Creates the spectator Helix manager for the broker. Returns a 
[CompactAssignmentZkHelixManager] when
+  /// [Broker#CONFIG_OF_ROUTING_COMPACT_ASSIGNMENT_READER_ENABLED] is on, 
otherwise the default Helix manager.
+  public static HelixManager createSpectatorHelixManager(PinotConfiguration 
brokerConf, String clusterName,
+      String instanceId, String zkServers) {
+    if 
(brokerConf.getProperty(Broker.CONFIG_OF_ROUTING_COMPACT_ASSIGNMENT_READER_ENABLED,
+        Broker.DEFAULT_ROUTING_COMPACT_ASSIGNMENT_READER_ENABLED)) {
+      LOGGER.info("Using the compact ideal state and external view reader for 
cluster: {}", clusterName);
+      return new CompactAssignmentZkHelixManager(clusterName, instanceId, 
zkServers);
+    }
+    return HelixManagerFactory.getZKHelixManager(clusterName, instanceId, 
InstanceType.SPECTATOR, zkServers);
+  }
+
+  @Override
+  public void connect()
+      throws Exception {
+    super.connect();
+    installCompactSerializer();
+  }
+
+  /// Replaces the serializer of the ZooKeeper client created by [#connect].
+  private void installCompactSerializer() {
+    RealmAwareZkClient zkClient = _zkclient;
+    Preconditions.checkState(zkClient != null, "ZooKeeper client is not 
created for cluster: %s", getClusterName());
+    zkClient.setZkSerializer(createPathBasedSerializer(getClusterName()));
+  }
+
+  /// Returns the ZooKeeper client created by [#connect], for tests.
+  @VisibleForTesting
+  RealmAwareZkClient getZkClient() {
+    return _zkclient;
+  }
+
+  /// Returns a serializer that reads the table ideal states and external 
views of the given cluster with
+  /// [CompactZNRecordSerializer], and everything else (including the broker 
resource) with [ZNRecordSerializer].
+  private static PathBasedZkSerializer createPathBasedSerializer(String 
clusterName) {
+    ZNRecordSerializer defaultSerializer = new ZNRecordSerializer();
+    CompactZNRecordSerializer compactSerializer = new 
CompactZNRecordSerializer(defaultSerializer);
+    PropertyKey.Builder keyBuilder = new PropertyKey.Builder(clusterName);
+    String idealStatesPath = keyBuilder.idealStates().getPath();
+    String externalViewsPath = keyBuilder.externalViews().getPath();
+    // ChainedPathZkSerializer picks the longest matching path, so the broker 
resource paths override the parents
+    return ChainedPathZkSerializer.builder(defaultSerializer)
+        .serialize(idealStatesPath, compactSerializer)
+        .serialize(externalViewsPath, compactSerializer)
+        
.serialize(keyBuilder.idealStates(Helix.BROKER_RESOURCE_INSTANCE).getPath(), 
defaultSerializer)
+        
.serialize(keyBuilder.externalView(Helix.BROKER_RESOURCE_INSTANCE).getPath(), 
defaultSerializer)
+        .build();
+  }
+}
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/MultiClusterHelixBrokerStarter.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/MultiClusterHelixBrokerStarter.java
index a1e05842c37..36ce21bc594 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/MultiClusterHelixBrokerStarter.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/broker/helix/MultiClusterHelixBrokerStarter.java
@@ -27,8 +27,6 @@ import java.util.Map;
 import java.util.Set;
 import org.apache.helix.HelixConstants.ChangeType;
 import org.apache.helix.HelixManager;
-import org.apache.helix.HelixManagerFactory;
-import org.apache.helix.InstanceType;
 import org.apache.pinot.broker.routing.manager.MultiClusterRoutingManager;
 import 
org.apache.pinot.broker.routing.manager.RemoteClusterBrokerRoutingManager;
 import org.apache.pinot.common.config.provider.TableCache;
@@ -137,8 +135,9 @@ public class MultiClusterHelixBrokerStarter extends 
BaseBrokerStarter {
       String clusterName = entry.getKey();
       String zkServers = entry.getValue();
       try {
-        HelixManager helixManager = HelixManagerFactory.getZKHelixManager(
-            clusterName, _instanceId, InstanceType.SPECTATOR, zkServers);
+        HelixManager helixManager =
+            
CompactAssignmentZkHelixManager.createSpectatorHelixManager(_brokerConf, 
clusterName, _instanceId,
+                zkServers);
         helixManager.connect();
         _remoteSpectatorHelixManager.put(clusterName, helixManager);
         LOGGER.info("[multi-cluster] Connected to remote cluster '{}' at ZK: 
{}", clusterName, zkServers);
diff --git 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
index 5aabc29e179..bb28278db52 100644
--- 
a/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
+++ 
b/pinot-broker/src/main/java/org/apache/pinot/broker/routing/manager/BaseBrokerRoutingManager.java
@@ -81,6 +81,7 @@ import org.apache.pinot.common.metrics.BrokerTimer;
 import org.apache.pinot.common.request.BrokerRequest;
 import org.apache.pinot.common.utils.HashUtil;
 import org.apache.pinot.common.utils.config.QueryOptionsUtils;
+import org.apache.pinot.common.utils.helix.HelixHelper;
 import org.apache.pinot.core.routing.RoutingManager;
 import org.apache.pinot.core.routing.RoutingTable;
 import org.apache.pinot.core.routing.SegmentsToQuery;
@@ -366,7 +367,8 @@ public abstract class BaseBrokerRoutingManager
     ZNRecord znRecord = _zkDataAccessor.get(idealStatePath, stat, 
AccessOption.PERSISTENT);
     if (znRecord != null) {
       znRecord.setVersion(stat.getVersion());
-      return new IdealState(znRecord);
+      // Skips the copy of the outer segment map when the record comes from 
the compact reader
+      return HelixHelper.toIdealState(znRecord);
     } else {
       return null;
     }
@@ -378,7 +380,7 @@ public abstract class BaseBrokerRoutingManager
     ZNRecord znRecord = _zkDataAccessor.get(externalViewPath, stat, 
AccessOption.PERSISTENT);
     if (znRecord != null) {
       znRecord.setVersion(stat.getVersion());
-      return new ExternalView(znRecord);
+      return HelixHelper.toExternalView(znRecord);
     } else {
       return null;
     }
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/broker/helix/CompactAssignmentZkHelixManagerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/broker/helix/CompactAssignmentZkHelixManagerTest.java
new file mode 100644
index 00000000000..f363d4bc275
--- /dev/null
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/broker/helix/CompactAssignmentZkHelixManagerTest.java
@@ -0,0 +1,219 @@
+/**
+ * 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.broker.helix;
+
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.apache.helix.AccessOption;
+import org.apache.helix.BaseDataAccessor;
+import org.apache.helix.HelixAdmin;
+import org.apache.helix.HelixManager;
+import org.apache.helix.PropertyKey;
+import org.apache.helix.manager.zk.ZKHelixAdmin;
+import org.apache.helix.model.ExternalView;
+import org.apache.helix.model.IdealState;
+import org.apache.helix.zookeeper.api.client.RealmAwareZkClient;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.impl.client.ZkClient;
+import org.apache.helix.zookeeper.zkclient.ZkConnection;
+import org.apache.helix.zookeeper.zkclient.serialize.PathBasedZkSerializer;
+import org.apache.pinot.common.utils.ZkStarter;
+import org.apache.pinot.common.utils.helix.ImmutableSortedArrayMap;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants.Broker;
+import org.apache.pinot.spi.utils.CommonConstants.Helix;
+import org.apache.pinot.util.TestUtils;
+import org.apache.zookeeper.Watcher;
+import org.apache.zookeeper.ZooKeeper;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+
+
+/// Checks that [CompactAssignmentZkHelixManager] installs
+/// [org.apache.pinot.common.utils.helix.CompactZNRecordSerializer] on the 
client that Helix creates in `connect()`, for
+/// table ideal states and external views only.
+///
+/// This test fails when a Helix upgrade changes how 
[org.apache.helix.manager.zk.ZKHelixManager] creates its ZooKeeper
+/// client or its serializer, for example by renaming the protected 
`_zkclient` field (compile error) or by creating the
+/// client outside of `connect()` (the records read through the manager would 
no longer be compact).
+public class CompactAssignmentZkHelixManagerTest {
+  private static final String CLUSTER_NAME = 
"CompactAssignmentZkHelixManagerTest";
+  private static final String TABLE_NAME = "myTable_OFFLINE";
+
+  private ZkStarter.ZookeeperInstance _zookeeperInstance;
+  private ZkClient _zkClient;
+
+  @BeforeClass
+  public void setUp() {
+    _zookeeperInstance = ZkStarter.startLocalZkServer();
+    String zkUrl = _zookeeperInstance.getZkUrl();
+    HelixAdmin helixAdmin = new 
ZKHelixAdmin.Builder().setZkAddress(zkUrl).build();
+    try {
+      helixAdmin.addCluster(CLUSTER_NAME);
+    } finally {
+      helixAdmin.close();
+    }
+    _zkClient = new ZkClient.Builder().setZkServer(zkUrl).setZkSerializer(new 
ZNRecordSerializer()).build();
+    PropertyKey.Builder keyBuilder = new PropertyKey.Builder(CLUSTER_NAME);
+    _zkClient.createPersistent(keyBuilder.idealStates(TABLE_NAME).getPath(), 
assignment(TABLE_NAME));
+    _zkClient.createPersistent(keyBuilder.externalView(TABLE_NAME).getPath(), 
assignment(TABLE_NAME));
+    
_zkClient.createPersistent(keyBuilder.idealStates(Helix.BROKER_RESOURCE_INSTANCE).getPath(),
+        assignment(Helix.BROKER_RESOURCE_INSTANCE));
+    
_zkClient.createPersistent(keyBuilder.externalView(Helix.BROKER_RESOURCE_INSTANCE).getPath(),
+        assignment(Helix.BROKER_RESOURCE_INSTANCE));
+  }
+
+  @AfterClass
+  public void tearDown() {
+    if (_zkClient != null) {
+      _zkClient.close();
+    }
+    ZkStarter.stopLocalZkServer(_zookeeperInstance);
+  }
+
+  private static ZNRecord assignment(String id) {
+    ZNRecord record = new ZNRecord(id);
+    // Compressed, because the compact serializer only parses gzip-compressed 
znodes itself
+    record.setBooleanField(ZNRecord.ENABLE_COMPRESSION_BOOLEAN_FIELD, true);
+    record.setSimpleField("REBALANCE_MODE", "CUSTOMIZED");
+    record.setMapField("segment_1", Map.of("Server_b_8098", "ONLINE", 
"Server_a_8098", "ONLINE"));
+    record.setMapField("segment_0", Map.of("Server_b_8098", "ONLINE", 
"Server_a_8098", "ONLINE"));
+    return record;
+  }
+
+  @Test
+  public void testFactoryHonorsFlag() {
+    String zkUrl = _zookeeperInstance.getZkUrl();
+    HelixManager defaultManager =
+        CompactAssignmentZkHelixManager.createSpectatorHelixManager(new 
PinotConfiguration(), CLUSTER_NAME,
+            "Broker_localhost_1", zkUrl);
+    assertFalse(defaultManager instanceof CompactAssignmentZkHelixManager);
+    PinotConfiguration conf = new PinotConfiguration();
+    
conf.setProperty(Broker.CONFIG_OF_ROUTING_COMPACT_ASSIGNMENT_READER_ENABLED, 
true);
+    HelixManager compactManager =
+        CompactAssignmentZkHelixManager.createSpectatorHelixManager(conf, 
CLUSTER_NAME, "Broker_localhost_1", zkUrl);
+    assertTrue(compactManager instanceof CompactAssignmentZkHelixManager);
+  }
+
+  @Test
+  public void testReadsThroughManagerAreCompact()
+      throws Exception {
+    CompactAssignmentZkHelixManager manager =
+        new CompactAssignmentZkHelixManager(CLUSTER_NAME, 
"Broker_localhost_2", _zookeeperInstance.getZkUrl());
+    manager.connect();
+    try {
+      assertReads(manager);
+      // A new connect() creates a new client; the serializer must be 
installed on it again
+      manager.disconnect();
+      manager.connect();
+      assertReads(manager);
+    } finally {
+      manager.disconnect();
+    }
+  }
+
+  @Test
+  public void testSerializerSurvivesSessionExpiry()
+      throws Exception {
+    CompactAssignmentZkHelixManager manager =
+        new CompactAssignmentZkHelixManager(CLUSTER_NAME, 
"Broker_localhost_3", _zookeeperInstance.getZkUrl());
+    manager.connect();
+    try {
+      assertReads(manager);
+      RealmAwareZkClient zkClient = manager.getZkClient();
+      PathBasedZkSerializer serializer = 
manager.getZkClient().getZkSerializer();
+      long oldSessionId = ((ZkClient) zkClient).getSessionId();
+      expireSession((ZkClient) zkClient);
+      TestUtils.waitForCondition(aVoid -> {
+        long sessionId = ((ZkClient) zkClient).getSessionId();
+        return sessionId != 0 && sessionId != oldSessionId && 
zkClient.waitUntilConnected(1, TimeUnit.SECONDS);
+      }, 30_000L, "Failed to get a new ZooKeeper session");
+      // Helix keeps the client (and so the serializer) across a session expiry
+      assertSame(manager.getZkClient(), zkClient);
+      assertSame(manager.getZkClient().getZkSerializer(), serializer);
+      assertReads(manager);
+    } finally {
+      manager.disconnect();
+    }
+  }
+
+  /// Expires the session of the given client: opens a second connection with 
the same session id and password, then
+  /// closes it. The server then expires the session, and the client gets a 
new one.
+  private void expireSession(ZkClient zkClient)
+      throws Exception {
+    ZooKeeper zooKeeper = ((ZkConnection) 
zkClient.getConnection()).getZookeeper();
+    CountDownLatch connected = new CountDownLatch(1);
+    ZooKeeper duplicate = new ZooKeeper(_zookeeperInstance.getZkUrl(), 10_000, 
event -> {
+      if (event.getState() == Watcher.Event.KeeperState.SyncConnected) {
+        connected.countDown();
+      }
+    }, zooKeeper.getSessionId(), zooKeeper.getSessionPasswd());
+    try {
+      assertTrue(connected.await(30, TimeUnit.SECONDS), "Failed to connect 
with the duplicate session");
+    } finally {
+      duplicate.close();
+    }
+  }
+
+  private static void assertReads(CompactAssignmentZkHelixManager manager) {
+    assertNotNull(manager.getZkClient().getZkSerializer());
+    BaseDataAccessor<ZNRecord> accessor = 
manager.getHelixDataAccessor().getBaseDataAccessor();
+    PropertyKey.Builder keyBuilder = new PropertyKey.Builder(CLUSTER_NAME);
+
+    // Table ideal state and external view: compact, equal to the written 
content, inner maps shared
+    for (String path : new String[]{
+        keyBuilder.idealStates(TABLE_NAME).getPath(), 
keyBuilder.externalView(TABLE_NAME).getPath()
+    }) {
+      ZNRecord record = accessor.get(path, null, AccessOption.PERSISTENT);
+      assertTrue(record.getMapFields() instanceof ImmutableSortedArrayMap, 
path);
+      assertEquals(record, assignment(TABLE_NAME));
+      assertSame(record.getMapField("segment_0"), 
record.getMapField("segment_1"));
+    }
+
+    // Broker resource: default deserializer
+    for (String path : new String[]{
+        keyBuilder.idealStates(Helix.BROKER_RESOURCE_INSTANCE).getPath(),
+        keyBuilder.externalView(Helix.BROKER_RESOURCE_INSTANCE).getPath()
+    }) {
+      ZNRecord record = accessor.get(path, null, AccessOption.PERSISTENT);
+      assertFalse(record.getMapFields() instanceof ImmutableSortedArrayMap, 
path);
+      assertEquals(record, assignment(Helix.BROKER_RESOURCE_INSTANCE));
+    }
+
+    // Readers through the data accessor and the admin of the manager see the 
compact records too
+    IdealState idealState = 
manager.getHelixDataAccessor().getProperty(keyBuilder.idealStates(TABLE_NAME));
+    assertTrue(idealState.getInstanceStateMap("segment_0") instanceof 
ImmutableSortedArrayMap);
+    ExternalView externalView = 
manager.getClusterManagmentTool().getResourceExternalView(CLUSTER_NAME, 
TABLE_NAME);
+    assertTrue(externalView.getStateMap("segment_0") instanceof 
ImmutableSortedArrayMap);
+    assertEquals(new TreeMap<>(externalView.getRecord().getMapFields()), 
assignment(TABLE_NAME).getMapFields());
+    ExternalView brokerResource =
+        
manager.getClusterManagmentTool().getResourceExternalView(CLUSTER_NAME, 
Helix.BROKER_RESOURCE_INSTANCE);
+    assertFalse(brokerResource.getStateMap("segment_0") instanceof 
ImmutableSortedArrayMap);
+  }
+}
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/instanceselector/CompactAssignmentInstanceSelectorTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/instanceselector/CompactAssignmentInstanceSelectorTest.java
new file mode 100644
index 00000000000..7a8ff4b11af
--- /dev/null
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/instanceselector/CompactAssignmentInstanceSelectorTest.java
@@ -0,0 +1,179 @@
+/**
+ * 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.instanceselector;
+
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import org.apache.helix.model.ExternalView;
+import org.apache.helix.model.IdealState;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.util.GZipCompressionUtil;
+import org.apache.helix.zookeeper.zkclient.serialize.ZkSerializer;
+import org.apache.pinot.common.metrics.BrokerMetrics;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.request.PinotQuery;
+import org.apache.pinot.common.utils.helix.CompactZNRecordSerializer;
+import org.apache.pinot.common.utils.helix.HelixHelper;
+import org.apache.pinot.common.utils.helix.ImmutableSortedArrayMap;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.testng.annotations.DataProvider;
+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.ERROR;
+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.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+
+
+/// Checks that the instance selectors route the same way, and do not mutate 
the assignment, when the ideal state and
+/// external view come from [CompactZNRecordSerializer] instead of 
[ZNRecordSerializer].
+public class CompactAssignmentInstanceSelectorTest {
+  private static final String TABLE_NAME = "testTable_OFFLINE";
+  private static final InstanceSelectorConfig CONFIG = new 
InstanceSelectorConfig(false, 300, false);
+
+  @DataProvider
+  public Object[][] selectorTypes() {
+    return new Object[][]{{"balanced", false}, {"replicaGroup", false}, 
{"strictReplicaGroup", true}};
+  }
+
+  private static InstanceSelector newSelector(String type) {
+    switch (type) {
+      case "balanced":
+        return new BalancedInstanceSelector();
+      case "replicaGroup":
+        return new ReplicaGroupInstanceSelector();
+      default:
+        return new StrictReplicaGroupInstanceSelector();
+    }
+  }
+
+  @Test(dataProvider = "selectorTypes")
+  public void testSameSelection(String type, boolean upsert) {
+    Random random = new Random(7);
+    List<String> instances = new ArrayList<>();
+    for (int i = 0; i < 6; i++) {
+      instances.add("Server_host" + i + "_8098");
+    }
+    ZNRecord idealStateRecord = new ZNRecord(TABLE_NAME);
+    idealStateRecord.setSimpleField("REBALANCE_MODE", "CUSTOMIZED");
+    ZNRecord externalViewRecord = new ZNRecord(TABLE_NAME);
+    // Compressed, because the compact serializer only parses gzip-compressed 
znodes itself
+    
idealStateRecord.setBooleanField(ZNRecord.ENABLE_COMPRESSION_BOOLEAN_FIELD, 
true);
+    
externalViewRecord.setBooleanField(ZNRecord.ENABLE_COMPRESSION_BOOLEAN_FIELD, 
true);
+    List<String> segments = new ArrayList<>();
+    for (int i = 0; i < 300; i++) {
+      String segment = "segment_" + i;
+      segments.add(segment);
+      // Replica groups: (0, 3), (1, 4), (2, 5); insertion order is not sorted 
on purpose
+      int group = i % 3;
+      Map<String, String> idealStateMap = new LinkedHashMap<>();
+      idealStateMap.put(instances.get(group + 3), i % 10 == 0 ? CONSUMING : 
ONLINE);
+      idealStateMap.put(instances.get(group), ONLINE);
+      idealStateRecord.setMapField(segment, idealStateMap);
+      if (i % 50 == 1) {
+        // Missing from the external view
+        continue;
+      }
+      Map<String, String> externalViewMap = new LinkedHashMap<>();
+      for (Map.Entry<String, String> entry : idealStateMap.entrySet()) {
+        int r = random.nextInt(20);
+        externalViewMap.put(entry.getKey(), r == 0 ? ERROR : r == 1 ? OFFLINE 
: entry.getValue());
+      }
+      externalViewRecord.setMapField(segment, externalViewMap);
+    }
+    ZNRecordSerializer defaultSerializer = new ZNRecordSerializer();
+    byte[] idealStateBytes = defaultSerializer.serialize(idealStateRecord);
+    byte[] externalViewBytes = defaultSerializer.serialize(externalViewRecord);
+    assertTrue(GZipCompressionUtil.isCompressed(idealStateBytes));
+    assertTrue(GZipCompressionUtil.isCompressed(externalViewBytes));
+    Set<String> enabledInstances = new HashSet<>(instances);
+
+    List<Map<String, String>> expected =
+        select(type, upsert, defaultSerializer, idealStateBytes, 
externalViewBytes, enabledInstances, segments, false);
+    List<Map<String, String>> actual =
+        select(type, upsert, new CompactZNRecordSerializer(), idealStateBytes, 
externalViewBytes, enabledInstances,
+            segments, true);
+    assertEquals(actual, expected);
+  }
+
+  /// Initializes a selector from the given znode bytes, runs a few 
selections, and returns their results.
+  private static List<Map<String, String>> select(String type, boolean upsert, 
ZkSerializer serializer,
+      byte[] idealStateBytes, byte[] externalViewBytes, Set<String> 
enabledInstances, List<String> segments,
+      boolean expectCompact) {
+    // Wrap the records as the routing manager does: a compact record is 
shared, a default one is copied
+    ZNRecord idealStateRecord = (ZNRecord) 
serializer.deserialize(idealStateBytes);
+    ZNRecord externalViewRecord = (ZNRecord) 
serializer.deserialize(externalViewBytes);
+    IdealState idealState = HelixHelper.toIdealState(idealStateRecord);
+    ExternalView externalView = HelixHelper.toExternalView(externalViewRecord);
+    assertEquals(HelixHelper.isCompact(idealStateRecord), expectCompact);
+    assertEquals(HelixHelper.isCompact(externalViewRecord), expectCompact);
+    if (expectCompact) {
+      // The selectors see the immutable outer maps of the parsed records, not 
copies
+      assertSame(idealState.getRecord().getMapFields(), 
idealStateRecord.getMapFields());
+      assertSame(externalView.getRecord().getMapFields(), 
externalViewRecord.getMapFields());
+      assertTrue(idealState.getRecord().getMapFields() instanceof 
ImmutableSortedArrayMap);
+      assertTrue(idealState.getInstanceStateMap("segment_0") instanceof 
ImmutableSortedArrayMap);
+      assertTrue(externalView.getStateMap("segment_0") instanceof 
ImmutableSortedArrayMap);
+    } else {
+      assertNotSame(idealState.getRecord().getMapFields(), 
idealStateRecord.getMapFields());
+    }
+    Set<String> onlineSegments = new HashSet<>();
+    for (Map.Entry<String, Map<String, String>> entry : 
idealState.getRecord().getMapFields().entrySet()) {
+      if (entry.getValue().containsValue(ONLINE) || 
entry.getValue().containsValue(CONSUMING)) {
+        onlineSegments.add(entry.getKey());
+      }
+    }
+
+    TableConfig tableConfig = mock(TableConfig.class);
+    when(tableConfig.getTableName()).thenReturn(TABLE_NAME);
+    when(tableConfig.isUpsertEnabled()).thenReturn(upsert);
+    @SuppressWarnings("unchecked")
+    ZkHelixPropertyStore<ZNRecord> propertyStore = 
mock(ZkHelixPropertyStore.class);
+    BrokerRequest brokerRequest = mock(BrokerRequest.class);
+    when(brokerRequest.getPinotQuery()).thenReturn(mock(PinotQuery.class));
+
+    InstanceSelector selector = newSelector(type);
+    selector.init(tableConfig, propertyStore, mock(BrokerMetrics.class), null, 
Clock.systemUTC(), CONFIG,
+        enabledInstances, Map.of(), idealState, externalView, onlineSegments);
+    List<Map<String, String>> results = new ArrayList<>();
+    for (int requestId = 0; requestId < 6; requestId++) {
+      InstanceSelector.SelectionResult result = selector.select(brokerRequest, 
segments, requestId);
+      results.add(result.getSegmentToInstanceMap());
+      results.add(Map.of("unavailable", 
String.valueOf(result.getUnavailableSegments())));
+    }
+    // A second assignment change on the same selector
+    selector.onAssignmentChange(idealState, externalView, onlineSegments);
+    results.add(selector.select(brokerRequest, segments, 
0).getSegmentToInstanceMap());
+    return results;
+  }
+}
diff --git 
a/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/CompactAssignmentRoutingManagerTest.java
 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/CompactAssignmentRoutingManagerTest.java
new file mode 100644
index 00000000000..bdc5024e98b
--- /dev/null
+++ 
b/pinot-broker/src/test/java/org/apache/pinot/broker/routing/manager/CompactAssignmentRoutingManagerTest.java
@@ -0,0 +1,410 @@
+/**
+ * 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.manager;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.concurrent.TimeUnit;
+import org.apache.helix.AccessOption;
+import org.apache.helix.HelixAdmin;
+import org.apache.helix.HelixConstants.ChangeType;
+import org.apache.helix.HelixManager;
+import org.apache.helix.HelixManagerFactory;
+import org.apache.helix.InstanceType;
+import org.apache.helix.PropertyKey;
+import org.apache.helix.manager.zk.ZKHelixAdmin;
+import org.apache.helix.model.InstanceConfig;
+import org.apache.helix.store.zk.ZkHelixPropertyStore;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.impl.client.ZkClient;
+import org.apache.pinot.broker.broker.helix.CompactAssignmentZkHelixManager;
+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.metrics.BrokerMetrics;
+import org.apache.pinot.common.request.BrokerRequest;
+import org.apache.pinot.common.utils.ZkStarter;
+import org.apache.pinot.common.utils.helix.HelixHelper;
+import org.apache.pinot.core.routing.RoutingTable;
+import org.apache.pinot.core.routing.SegmentsToQuery;
+import org.apache.pinot.core.routing.TablePartitionInfo;
+import org.apache.pinot.core.routing.TablePartitionReplicatedServersInfo;
+import org.apache.pinot.core.routing.timeboundary.TimeBoundaryInfo;
+import org.apache.pinot.core.transport.ServerInstance;
+import 
org.apache.pinot.core.transport.server.routing.stats.ServerRoutingStatsManager;
+import org.apache.pinot.segment.spi.partition.metadata.ColumnPartitionMetadata;
+import org.apache.pinot.spi.config.table.ColumnPartitionConfig;
+import org.apache.pinot.spi.config.table.RoutingConfig;
+import org.apache.pinot.spi.config.table.SegmentPartitionConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import org.apache.pinot.spi.utils.CommonConstants;
+import 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.apache.pinot.spi.utils.builder.TableNameBuilder;
+import org.apache.pinot.sql.parsers.CalciteSqlCompiler;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.mockito.Mockito.mock;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+
+/// Checks that a [BrokerRoutingManager] on a 
[CompactAssignmentZkHelixManager] routes exactly like one on the default
+/// Helix manager. The routing manager wraps the compact records without 
copying them (see
+/// [HelixHelper#toIdealState]), so every routing component (instance 
selector, segment pruners, time
+/// boundary manager, segment partition metadata manager) reads the immutable 
outer map of the parsed record.
+///
+/// The test writes a hybrid table into a local ZooKeeper, builds the routing 
in both managers, compares what they
+/// return, then changes the assignment and compares again after the 
assignment change.
+public class CompactAssignmentRoutingManagerTest {
+  private static final String CLUSTER_NAME = 
"CompactAssignmentRoutingManagerTest";
+  private static final String RAW_TABLE_NAME = "myTable";
+  private static final String OFFLINE_TABLE_NAME = 
TableNameBuilder.OFFLINE.tableNameWithType(RAW_TABLE_NAME);
+  private static final String REALTIME_TABLE_NAME = 
TableNameBuilder.REALTIME.tableNameWithType(RAW_TABLE_NAME);
+  private static final String TIME_COLUMN = "ts";
+  private static final String PARTITION_COLUMN = "memberId";
+  private static final int NUM_PARTITIONS = 4;
+  private static final int NUM_SERVERS = 4;
+  private static final long BASE_TIME_MS = 1_700_000_000_000L;
+  private static final long HOUR_MS = TimeUnit.HOURS.toMillis(1);
+  private static final List<String> QUERIES = List.of("SELECT * FROM myTable",
+      "SELECT * FROM myTable WHERE memberId = 2",
+      "SELECT * FROM myTable WHERE ts > " + (BASE_TIME_MS + 10 * HOUR_MS),
+      "SELECT * FROM myTable WHERE memberId IN (1, 3) AND ts < " + 
(BASE_TIME_MS + 6 * HOUR_MS));
+
+  private ZkStarter.ZookeeperInstance _zookeeperInstance;
+  private ZkClient _zkClient;
+  private HelixManager _defaultHelixManager;
+  private HelixManager _compactHelixManager;
+  private ZkHelixPropertyStore<ZNRecord> _propertyStore;
+  private BrokerRoutingManager _defaultRoutingManager;
+  private BrokerRoutingManager _compactRoutingManager;
+
+  @BeforeClass
+  public void setUp()
+      throws Exception {
+    _zookeeperInstance = ZkStarter.startLocalZkServer();
+    String zkUrl = _zookeeperInstance.getZkUrl();
+    HelixAdmin helixAdmin = new 
ZKHelixAdmin.Builder().setZkAddress(zkUrl).build();
+    try {
+      helixAdmin.addCluster(CLUSTER_NAME);
+      for (int i = 0; i < NUM_SERVERS; i++) {
+        InstanceConfig instanceConfig = new InstanceConfig(server(i));
+        instanceConfig.setHostName("host" + i);
+        instanceConfig.setPort(Integer.toString(8000 + i));
+        helixAdmin.addInstance(CLUSTER_NAME, instanceConfig);
+      }
+    } finally {
+      helixAdmin.close();
+    }
+    _zkClient = new ZkClient.Builder().setZkServer(zkUrl).setZkSerializer(new 
ZNRecordSerializer()).build();
+
+    _defaultHelixManager =
+        HelixManagerFactory.getZKHelixManager(CLUSTER_NAME, 
"Broker_default_8099", InstanceType.SPECTATOR, zkUrl);
+    _defaultHelixManager.connect();
+    _compactHelixManager =
+        new CompactAssignmentZkHelixManager(CLUSTER_NAME, 
"Broker_compact_8099", zkUrl);
+    _compactHelixManager.connect();
+    _propertyStore = _defaultHelixManager.getHelixPropertyStore();
+
+    Schema schema = new Schema.SchemaBuilder().setSchemaName(RAW_TABLE_NAME)
+        .addSingleValueDimension(PARTITION_COLUMN, FieldSpec.DataType.INT)
+        .addDateTime(TIME_COLUMN, FieldSpec.DataType.LONG, 
"1:MILLISECONDS:EPOCH", "1:MILLISECONDS")
+        .build();
+    ZKMetadataProvider.setSchema(_propertyStore, schema);
+    ZKMetadataProvider.setTableConfig(_propertyStore, 
tableConfig(TableType.OFFLINE));
+    ZKMetadataProvider.setTableConfig(_propertyStore, 
tableConfig(TableType.REALTIME));
+
+    // Offline: 20 segments on 2 replicas each; one replica in ERROR and one 
segment missing from the external view
+    Map<String, Map<String, String>> offlineIdealState = new TreeMap<>();
+    Map<String, Map<String, String>> offlineExternalView = new TreeMap<>();
+    for (int i = 0; i < 20; i++) {
+      String segment = offlineSegment(i);
+      setSegmentZkMetadata(OFFLINE_TABLE_NAME, segment, i % NUM_PARTITIONS, 
BASE_TIME_MS + i * HOUR_MS, false);
+      Map<String, String> instanceStateMap = replicas(i, 
SegmentStateModel.ONLINE);
+      offlineIdealState.put(segment, instanceStateMap);
+      if (i == 7) {
+        continue;
+      }
+      Map<String, String> externalViewStateMap = new 
TreeMap<>(instanceStateMap);
+      if (i == 5) {
+        externalViewStateMap.put(server(i % NUM_SERVERS), 
SegmentStateModel.ERROR);
+      }
+      offlineExternalView.put(segment, externalViewStateMap);
+    }
+    writeAssignment(OFFLINE_TABLE_NAME, offlineIdealState, 
offlineExternalView, true);
+
+    // Realtime: 3 sequences per partition, the last one CONSUMING
+    Map<String, Map<String, String>> realtimeAssignment = new TreeMap<>();
+    for (int partition = 0; partition < NUM_PARTITIONS; partition++) {
+      for (int sequence = 0; sequence < 3; sequence++) {
+        String segment = RAW_TABLE_NAME + "__" + partition + "__" + sequence + 
"__20240101T0000Z";
+        boolean consuming = sequence == 2;
+        setSegmentZkMetadata(REALTIME_TABLE_NAME, segment, partition,
+            BASE_TIME_MS + (15 + sequence) * HOUR_MS, consuming);
+        realtimeAssignment.put(segment,
+            replicas(partition, consuming ? SegmentStateModel.CONSUMING : 
SegmentStateModel.ONLINE));
+      }
+    }
+    writeAssignment(REALTIME_TABLE_NAME, realtimeAssignment, 
realtimeAssignment, true);
+
+    _defaultRoutingManager = newRoutingManager(_defaultHelixManager);
+    _compactRoutingManager = newRoutingManager(_compactHelixManager);
+  }
+
+  @AfterClass
+  public void tearDown() {
+    if (_defaultRoutingManager != null) {
+      _defaultRoutingManager.stop();
+    }
+    if (_compactRoutingManager != null) {
+      _compactRoutingManager.stop();
+    }
+    if (_defaultHelixManager != null) {
+      _defaultHelixManager.disconnect();
+    }
+    if (_compactHelixManager != null) {
+      _compactHelixManager.disconnect();
+    }
+    if (_zkClient != null) {
+      _zkClient.close();
+    }
+    ZkStarter.stopLocalZkServer(_zookeeperInstance);
+  }
+
+  private static String server(int i) {
+    return "Server_host" + i + "_" + (8000 + i);
+  }
+
+  private static String offlineSegment(int i) {
+    return OFFLINE_TABLE_NAME + "_" + i;
+  }
+
+  private static Map<String, String> replicas(int i, String state) {
+    Map<String, String> instanceStateMap = new TreeMap<>();
+    instanceStateMap.put(server(i % NUM_SERVERS), state);
+    instanceStateMap.put(server((i + 1) % NUM_SERVERS), state);
+    return instanceStateMap;
+  }
+
+  private static TableConfig tableConfig(TableType tableType) {
+    return new TableConfigBuilder(tableType).setTableName(RAW_TABLE_NAME)
+        .setTimeColumnName(TIME_COLUMN)
+        .setSegmentPartitionConfig(new SegmentPartitionConfig(
+            Map.of(PARTITION_COLUMN, new ColumnPartitionConfig("Modulo", 
NUM_PARTITIONS))))
+        .setRoutingConfig(new RoutingConfig(null,
+            List.of(RoutingConfig.PARTITION_SEGMENT_PRUNER_TYPE, 
RoutingConfig.TIME_SEGMENT_PRUNER_TYPE), null, false))
+        .build();
+  }
+
+  private void setSegmentZkMetadata(String tableNameWithType, String segment, 
int partition, long endTimeMs,
+      boolean consuming) {
+    SegmentZKMetadata segmentZKMetadata = new SegmentZKMetadata(segment);
+    // Old segments, so that the instance selectors do not treat them as new
+    segmentZKMetadata.setCreationTime(BASE_TIME_MS);
+    segmentZKMetadata.setPushTime(BASE_TIME_MS);
+    segmentZKMetadata.setPartitionMetadata(new 
SegmentPartitionMetadata(Map.of(PARTITION_COLUMN,
+        new ColumnPartitionMetadata("Modulo", NUM_PARTITIONS, 
Set.of(partition), null))));
+    if (consuming) {
+      
segmentZKMetadata.setStatus(CommonConstants.Segment.Realtime.Status.IN_PROGRESS);
+    } else {
+      segmentZKMetadata.setStartTime(endTimeMs - HOUR_MS);
+      segmentZKMetadata.setEndTime(endTimeMs);
+      segmentZKMetadata.setTimeUnit(TimeUnit.MILLISECONDS);
+      segmentZKMetadata.setTotalDocs(1000);
+    }
+    ZKMetadataProvider.setSegmentZKMetadata(_propertyStore, tableNameWithType, 
segmentZKMetadata);
+  }
+
+  private void writeAssignment(String tableNameWithType, Map<String, 
Map<String, String>> idealState,
+      Map<String, Map<String, String>> externalView, boolean create) {
+    PropertyKey.Builder keyBuilder = new PropertyKey.Builder(CLUSTER_NAME);
+    ZNRecord idealStateRecord = new ZNRecord(tableNameWithType);
+    idealStateRecord.setSimpleField("IDEAL_STATE_MODE", "CUSTOMIZED");
+    idealStateRecord.setSimpleField("REBALANCE_MODE", "CUSTOMIZED");
+    idealStateRecord.setSimpleField("STATE_MODEL_DEF_REF", 
"SegmentOnlineOfflineStateModel");
+    idealStateRecord.setSimpleField("NUM_PARTITIONS", 
Integer.toString(idealState.size()));
+    idealStateRecord.setSimpleField("HELIX_ENABLED", "true");
+    idealStateRecord.setMapFields(idealState);
+    ZNRecord externalViewRecord = new ZNRecord(tableNameWithType);
+    externalViewRecord.setMapFields(externalView);
+    // Compressed, because the compact serializer only parses gzip-compressed 
znodes itself
+    
idealStateRecord.setBooleanField(ZNRecord.ENABLE_COMPRESSION_BOOLEAN_FIELD, 
true);
+    
externalViewRecord.setBooleanField(ZNRecord.ENABLE_COMPRESSION_BOOLEAN_FIELD, 
true);
+    String idealStatePath = 
keyBuilder.idealStates(tableNameWithType).getPath();
+    String externalViewPath = 
keyBuilder.externalView(tableNameWithType).getPath();
+    if (create) {
+      _zkClient.createPersistent(idealStatePath, idealStateRecord);
+      _zkClient.createPersistent(externalViewPath, externalViewRecord);
+    } else {
+      _zkClient.writeData(idealStatePath, idealStateRecord);
+      _zkClient.writeData(externalViewPath, externalViewRecord);
+    }
+  }
+
+  private BrokerRoutingManager newRoutingManager(HelixManager helixManager) {
+    PinotConfiguration config = new PinotConfiguration();
+    
config.setProperty(CommonConstants.Broker.CONFIG_OF_ENABLE_PARTITION_METADATA_MANAGER,
 true);
+    BrokerRoutingManager routingManager =
+        new BrokerRoutingManager(mock(BrokerMetrics.class), 
mock(ServerRoutingStatsManager.class), config);
+    routingManager.init(helixManager);
+    routingManager.processClusterChange(ChangeType.INSTANCE_CONFIG);
+    routingManager.buildRouting(OFFLINE_TABLE_NAME);
+    routingManager.buildRouting(REALTIME_TABLE_NAME);
+    return routingManager;
+  }
+
+  @Test
+  public void testSameRoutingAsDefaultReader() {
+    assertReadsAreCompact();
+    Map<String, Object> expected = snapshot(_defaultRoutingManager);
+    assertEquals(snapshot(_compactRoutingManager), expected);
+
+    // Sanity checks so that the comparison is not vacuous
+    assertEquals(expected.get(OFFLINE_TABLE_NAME + ".timeBoundary"),
+        TIME_COLUMN + "=" + (BASE_TIME_MS + 19 * HOUR_MS - 
TimeUnit.DAYS.toMillis(1)));
+    assertEquals(expected.get(OFFLINE_TABLE_NAME + ".disabled"), false);
+    assertNotNull(expected.get(OFFLINE_TABLE_NAME + ".partitions"));
+    assertNotNull(expected.get(REALTIME_TABLE_NAME + ".replicatedServers"));
+    @SuppressWarnings("unchecked")
+    Map<String, Object> filtered = (Map<String, Object>) 
expected.get(OFFLINE_TABLE_NAME + "." + QUERIES.get(1) + ".0");
+    assertEquals(filtered.get("numPruned"), 15);
+    @SuppressWarnings("unchecked")
+    Map<String, Object> all = (Map<String, Object>) 
expected.get(OFFLINE_TABLE_NAME + "." + QUERIES.get(0) + ".0");
+    assertEquals(all.get("unavailable"), List.of(offlineSegment(7)));
+
+    // Change the offline assignment: add a segment, recover the ERROR 
replica, drop a segment
+    Map<String, Map<String, String>> idealState = new TreeMap<>();
+    Map<String, Map<String, String>> externalView = new TreeMap<>();
+    for (int i = 1; i < 21; i++) {
+      String segment = offlineSegment(i);
+      if (i == 20) {
+        setSegmentZkMetadata(OFFLINE_TABLE_NAME, segment, i % NUM_PARTITIONS, 
BASE_TIME_MS + i * HOUR_MS, false);
+      }
+      idealState.put(segment, replicas(i, SegmentStateModel.ONLINE));
+      externalView.put(segment, replicas(i, SegmentStateModel.ONLINE));
+    }
+    writeAssignment(OFFLINE_TABLE_NAME, idealState, externalView, false);
+    _defaultRoutingManager.processClusterChange(ChangeType.IDEAL_STATE);
+    _defaultRoutingManager.processClusterChange(ChangeType.EXTERNAL_VIEW);
+    _compactRoutingManager.processClusterChange(ChangeType.IDEAL_STATE);
+    _compactRoutingManager.processClusterChange(ChangeType.EXTERNAL_VIEW);
+
+    assertReadsAreCompact();
+    Map<String, Object> expectedAfterChange = snapshot(_defaultRoutingManager);
+    assertEquals(snapshot(_compactRoutingManager), expectedAfterChange);
+    assertEquals(expectedAfterChange.get(OFFLINE_TABLE_NAME + ".timeBoundary"),
+        TIME_COLUMN + "=" + (BASE_TIME_MS + 20 * HOUR_MS - 
TimeUnit.DAYS.toMillis(1)));
+    @SuppressWarnings("unchecked")
+    Map<String, Object> allAfterChange =
+        (Map<String, Object>) expectedAfterChange.get(OFFLINE_TABLE_NAME + "." 
+ QUERIES.get(0) + ".0");
+    assertEquals(allAfterChange.get("unavailable"), List.of());
+  }
+
+  /// Checks the raw reads of the data accessors: the compact manager reads 
compact records and the default manager
+  /// reads regular records. The wrapping of compact records without a copy is 
checked in
+  /// `HelixHelperTest#testToIdealStateAndExternalViewShareCompactRecord`, not 
here.
+  private void assertReadsAreCompact() {
+    PropertyKey.Builder keyBuilder = new PropertyKey.Builder(CLUSTER_NAME);
+    for (String table : List.of(OFFLINE_TABLE_NAME, REALTIME_TABLE_NAME)) {
+      for (String path : List.of(keyBuilder.idealStates(table).getPath(), 
keyBuilder.externalView(table).getPath())) {
+        ZNRecord compact =
+            
_compactHelixManager.getHelixDataAccessor().getBaseDataAccessor().get(path, 
null, AccessOption.PERSISTENT);
+        assertTrue(HelixHelper.isCompact(compact), path);
+        ZNRecord standard =
+            
_defaultHelixManager.getHelixDataAccessor().getBaseDataAccessor().get(path, 
null, AccessOption.PERSISTENT);
+        assertFalse(HelixHelper.isCompact(standard), path);
+      }
+    }
+  }
+
+  /// Returns what the routing manager exposes for both tables, in a form that 
compares by value.
+  private static Map<String, Object> snapshot(BrokerRoutingManager 
routingManager) {
+    Map<String, Object> snapshot = new LinkedHashMap<>();
+    for (String table : List.of(OFFLINE_TABLE_NAME, REALTIME_TABLE_NAME)) {
+      snapshot.put(table + ".exists", routingManager.routingExists(table));
+      snapshot.put(table + ".disabled", routingManager.isTableDisabled(table));
+      snapshot.put(table + ".servingInstances", new 
TreeSet<>(routingManager.getServingInstances(table)));
+      TablePartitionInfo partitionInfo = 
routingManager.getTablePartitionInfo(table);
+      if (partitionInfo != null) {
+        List<List<String>> segmentsByPartition = new ArrayList<>();
+        for (List<String> segments : partitionInfo.getSegmentsByPartition()) {
+          segmentsByPartition.add(sorted(segments));
+        }
+        snapshot.put(table + ".partitions", partitionInfo.getPartitionColumn() 
+ "/"
+            + partitionInfo.getNumPartitions() + "/" + segmentsByPartition);
+      }
+      TablePartitionReplicatedServersInfo replicatedServersInfo =
+          routingManager.getTablePartitionReplicatedServersInfo(table);
+      if (replicatedServersInfo != null) {
+        List<String> partitions = new ArrayList<>();
+        for (TablePartitionReplicatedServersInfo.PartitionInfo info : 
replicatedServersInfo.getPartitionInfoMap()) {
+          partitions.add(info == null ? "null"
+              : new TreeSet<>(info._fullyReplicatedServers) + ":" + 
sorted(info._segments));
+        }
+        snapshot.put(table + ".replicatedServers",
+            partitions + "/" + 
sorted(replicatedServersInfo.getSegmentsWithInvalidPartition()));
+      }
+      for (String query : QUERIES) {
+        BrokerRequest brokerRequest = 
CalciteSqlCompiler.compileToBrokerRequest(query);
+        for (int requestId = 0; requestId < 4; requestId++) {
+          snapshot.put(table + "." + query + "." + requestId,
+              routing(routingManager.getRoutingTable(brokerRequest, table, 
requestId)));
+        }
+      }
+    }
+    TimeBoundaryInfo timeBoundaryInfo = 
routingManager.getTimeBoundaryInfo(OFFLINE_TABLE_NAME);
+    snapshot.put(OFFLINE_TABLE_NAME + ".timeBoundary",
+        timeBoundaryInfo == null ? null : timeBoundaryInfo.getTimeColumn() + 
"=" + timeBoundaryInfo.getTimeValue());
+    return snapshot;
+  }
+
+  private static Map<String, Object> routing(RoutingTable routingTable) {
+    Map<String, Object> routing = new LinkedHashMap<>();
+    Map<String, String> servers = new TreeMap<>();
+    for (Map.Entry<ServerInstance, SegmentsToQuery> entry : 
routingTable.getServerInstanceToSegmentsMap().entrySet()) {
+      servers.put(entry.getKey().getInstanceId(),
+          sorted(entry.getValue().getSegments()) + "+" + 
sorted(entry.getValue().getOptionalSegments()));
+    }
+    routing.put("servers", servers);
+    routing.put("unavailable", sorted(routingTable.getUnavailableSegments()));
+    routing.put("numPruned", routingTable.getNumPrunedSegments());
+    return routing;
+  }
+
+  private static List<String> sorted(List<String> list) {
+    List<String> sorted = new ArrayList<>(list);
+    sorted.sort(null);
+    return sorted;
+  }
+}
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/CompactZNRecordSerializer.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/CompactZNRecordSerializer.java
new file mode 100644
index 00000000000..a66f5c524c3
--- /dev/null
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/CompactZNRecordSerializer.java
@@ -0,0 +1,468 @@
+/**
+ * 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.utils.helix;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.GZIPInputStream;
+import javax.annotation.Nullable;
+import org.apache.helix.HelixDefinedState;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.util.GZipCompressionUtil;
+import org.apache.helix.zookeeper.zkclient.serialize.ZkSerializer;
+import 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// A [ZkSerializer] that reads gzip-compressed ideal state and external view 
znodes with less allocation than
+/// [ZNRecordSerializer], and returns an equal [ZNRecord].
+///
+/// Only gzip-compressed znodes use the compact parser. A plain (uncompressed) 
znode goes to [ZNRecordSerializer]
+/// unchanged. Pinot compresses large table ideal states, so plain znodes are 
the small ones, and there the compact
+/// parser does not pay off: in a size sweep it allocated about 1x to 3.5x as 
much and used 1x to 1.5x the CPU of
+/// [ZNRecordSerializer] up to about 200 segments. On gzip-compressed znodes 
of any size it allocated about 0.06x to
+/// 0.3x as much and used about 0.25x to 0.75x the CPU.
+///
+/// Compared with [ZNRecordSerializer#deserialize], for a gzip-compressed 
znode:
+/// - It streams a gzip-compressed znode through a [GZIPInputStream] straight 
into the JSON parser. It does not
+///   inflate the whole payload into a growing byte array first.
+/// - Its [JsonFactory] does not canonicalize or intern field names, so the 
parser does not build a symbol table with
+///   one entry per segment.
+/// - Instance names (the keys of the instance-state maps and the values of 
the list fields) are interned once per
+///   parse. Routing keys its server maps by interned instance ids, so lookups 
keep their identity fast path.
+/// - State values come from a fixed table of the known segment states. An 
unknown state is interned.
+/// - Segment names (the keys of the map fields) are interned, so they keep 
their identity with the names that the
+///   routing components already hold.
+/// - Equal instance-state maps within one znode share one 
[ImmutableSortedArrayMap]. The outer segment map is an
+///   [ImmutableSortedArrayMap] too.
+///
+/// The map fields of a record from the compact parser are immutable: any 
mutation throws
+/// [UnsupportedOperationException]. Simple fields and list fields are mutable 
[LinkedHashMap]s, as with
+/// [ZNRecordSerializer]. Scalar setters of the record (version, creation 
time, etc.) work as usual.
+///
+/// When the compact parser fails, the serializer logs a warning and falls 
back to [ZNRecordSerializer]. [#serialize]
+/// always delegates to [ZNRecordSerializer].
+///
+/// [HelixHelper#toIdealState] and [HelixHelper#toExternalView] wrap a record 
from the compact parser without the copy
+/// of the outer segment map that `new IdealState(record)` and `new 
ExternalView(record)` make. Those constructors
+/// share the inner instance-state maps, so an 
[org.apache.helix.model.IdealState] or
+/// [org.apache.helix.model.ExternalView] built from a compact record has 
immutable instance-state maps either way.
+/// A record from [ZNRecordSerializer] (a plain znode or the fallback) still 
goes through those constructors.
+///
+/// Thread-safety: the serializer is stateless apart from the shared 
[JsonFactory], which is thread-safe. Each call
+/// uses its own parser and dictionaries, so concurrent calls are safe.
+public class CompactZNRecordSerializer implements ZkSerializer {
+  private static final Logger LOGGER = 
LoggerFactory.getLogger(CompactZNRecordSerializer.class);
+
+  private static final JsonFactory JSON_FACTORY = JsonFactory.builder()
+      .disable(JsonFactory.Feature.CANONICALIZE_FIELD_NAMES)
+      .disable(JsonFactory.Feature.INTERN_FIELD_NAMES)
+      .build();
+
+  /// Jackson has its own input buffer, so the inflater only needs a small one.
+  private static final int GZIP_BUFFER_SIZE = 2 * 1024;
+  /// Small, so that the fixed cost per parse stays low; the arrays grow 
geometrically.
+  private static final int INITIAL_SEGMENT_CAPACITY = 16;
+  private static final int INITIAL_STATE_MAP_TABLE_CAPACITY = 16;
+  private static final int INITIAL_INSTANCE_CAPACITY = 8;
+
+  /// The states a segment replica can have. The constants are string literals 
or enum constant names, so they are
+  /// already interned.
+  private static final String[] KNOWN_STATES = {
+      SegmentStateModel.ONLINE, SegmentStateModel.CONSUMING, 
SegmentStateModel.OFFLINE, SegmentStateModel.ERROR,
+      HelixDefinedState.DROPPED.name()
+  };
+
+  private final ZNRecordSerializer _delegate;
+
+  public CompactZNRecordSerializer() {
+    this(new ZNRecordSerializer());
+  }
+
+  public CompactZNRecordSerializer(ZNRecordSerializer delegate) {
+    _delegate = delegate;
+  }
+
+  @Override
+  public byte[] serialize(Object data) {
+    return _delegate.serialize(data);
+  }
+
+  @Nullable
+  @Override
+  public Object deserialize(byte[] bytes) {
+    if (bytes == null || bytes.length == 0) {
+      // Reading a parent or empty node, same as ZNRecordSerializer
+      return null;
+    }
+    if (!GZipCompressionUtil.isCompressed(bytes)) {
+      // Plain znodes are small; the default deserializer is cheaper there 
(see the class documentation)
+      return _delegate.deserialize(bytes);
+    }
+    try {
+      return deserializeCompact(bytes);
+    } catch (Exception e) {
+      LOGGER.warn("Caught exception while deserializing ZNRecord with the 
compact reader, falling back to the "
+          + "default deserializer", e);
+      return _delegate.deserialize(bytes);
+    }
+  }
+
+  /// Parses the given gzip-compressed znode content with the compact parser. 
The content must be gzip-compressed;
+  /// [#deserialize] sends plain content to [ZNRecordSerializer] before 
calling this method. Throws on malformed input;
+  /// [#deserialize] then falls back.
+  static ZNRecord deserializeCompact(byte[] bytes)
+      throws IOException {
+    assert GZipCompressionUtil.isCompressed(bytes);
+    GZIPInputStream gzipInputStream = new GZIPInputStream(new 
ByteArrayInputStream(bytes), GZIP_BUFFER_SIZE);
+    try (JsonParser parser = JSON_FACTORY.createParser(gzipInputStream)) {
+      ZNRecord record = new Reader(parser).readRecord();
+      // The parser stops at the end of the record. GZIPInputStream verifies 
the CRC-32 and the size in the gzip
+      // trailer only when it reaches the end of the stream, so read the rest 
of the stream (as
+      // GZipCompressionUtil.uncompress does). A bad trailer throws, and 
deserialize falls back.
+      gzipInputStream.transferTo(OutputStream.nullOutputStream());
+      return record;
+    }
+  }
+
+  /// The state of one parse: the parser, the dictionaries, and scratch 
buffers. Not thread-safe; one per call.
+  private static final class Reader {
+    private final JsonParser _parser;
+    private final HashMap<String, String> _instanceNames = new HashMap<>();
+    private final StateMapTable _stateMaps = new StateMapTable();
+    private String[] _instanceKeys = new String[INITIAL_INSTANCE_CAPACITY];
+    private Object[] _instanceStates = new Object[INITIAL_INSTANCE_CAPACITY];
+
+    Reader(JsonParser parser) {
+      _parser = parser;
+    }
+
+    ZNRecord readRecord()
+        throws IOException {
+      expect(_parser.nextToken(), JsonToken.START_OBJECT);
+      String id = null;
+      Map<String, String> simpleFields = null;
+      Map<String, List<String>> listFields = null;
+      Map<String, Map<String, String>> mapFields = null;
+      byte[] rawPayload = null;
+      boolean hasSimpleFields = false;
+      boolean hasListFields = false;
+      boolean hasMapFields = false;
+      JsonToken token;
+      while ((token = _parser.nextToken()) == JsonToken.FIELD_NAME) {
+        String name = _parser.currentName();
+        token = _parser.nextToken();
+        switch (name) {
+          case "id":
+            id = token == JsonToken.VALUE_NULL ? null : scalarText(token);
+            break;
+          case "simpleFields":
+            hasSimpleFields = true;
+            simpleFields = token == JsonToken.VALUE_NULL ? null : 
readSimpleFields(token);
+            break;
+          case "listFields":
+            hasListFields = true;
+            listFields = token == JsonToken.VALUE_NULL ? null : 
readListFields(token);
+            break;
+          case "mapFields":
+            hasMapFields = true;
+            mapFields = token == JsonToken.VALUE_NULL ? null : 
readMapFields(token);
+            break;
+          case "rawPayload":
+            rawPayload = token == JsonToken.VALUE_NULL ? null : 
_parser.getBinaryValue();
+            break;
+          default:
+            // ZNRecord ignores unknown properties
+            _parser.skipChildren();
+            break;
+        }
+      }
+      // Like ObjectMapper.readValue (FAIL_ON_TRAILING_TOKENS is off), content 
after the record is ignored
+      expect(token, JsonToken.END_OBJECT);
+
+      ZNRecord record = new ZNRecord(id);
+      if (hasSimpleFields) {
+        record.setSimpleFields(simpleFields);
+      }
+      if (hasListFields) {
+        record.setListFields(listFields);
+      }
+      if (hasMapFields) {
+        record.setMapFields(mapFields);
+      }
+      if (rawPayload != null) {
+        record.setRawPayload(rawPayload);
+      }
+      return record;
+    }
+
+    private Map<String, String> readSimpleFields(JsonToken token)
+        throws IOException {
+      expect(token, JsonToken.START_OBJECT);
+      Map<String, String> simpleFields = new LinkedHashMap<>();
+      while ((token = _parser.nextToken()) == JsonToken.FIELD_NAME) {
+        // ZNRecordSerializer interns all field names
+        String key = _parser.currentName().intern();
+        token = _parser.nextToken();
+        simpleFields.put(key, token == JsonToken.VALUE_NULL ? null : 
scalarText(token));
+      }
+      expect(token, JsonToken.END_OBJECT);
+      return simpleFields;
+    }
+
+    private Map<String, List<String>> readListFields(JsonToken token)
+        throws IOException {
+      expect(token, JsonToken.START_OBJECT);
+      Map<String, List<String>> listFields = new LinkedHashMap<>();
+      while ((token = _parser.nextToken()) == JsonToken.FIELD_NAME) {
+        String key = _parser.currentName().intern();
+        token = _parser.nextToken();
+        if (token == JsonToken.VALUE_NULL) {
+          listFields.put(key, null);
+          continue;
+        }
+        expect(token, JsonToken.START_ARRAY);
+        List<String> list = new ArrayList<>();
+        while ((token = _parser.nextToken()) != JsonToken.END_ARRAY) {
+          // List fields hold instance names (e.g. the preference lists of 
SEMI_AUTO resources)
+          list.add(token == JsonToken.VALUE_NULL ? null : 
instanceName(scalarText(token)));
+        }
+        listFields.put(key, list);
+      }
+      expect(token, JsonToken.END_OBJECT);
+      return listFields;
+    }
+
+    private Map<String, Map<String, String>> readMapFields(JsonToken token)
+        throws IOException {
+      expect(token, JsonToken.START_OBJECT);
+      String[] segments = new String[INITIAL_SEGMENT_CAPACITY];
+      Object[] stateMaps = new Object[INITIAL_SEGMENT_CAPACITY];
+      int numSegments = 0;
+      while ((token = _parser.nextToken()) == JsonToken.FIELD_NAME) {
+        String segment = _parser.currentName().intern();
+        token = _parser.nextToken();
+        Map<String, String> stateMap = token == JsonToken.VALUE_NULL ? null : 
readStateMap(token);
+        if (numSegments == segments.length) {
+          int newCapacity = segments.length * 2;
+          segments = Arrays.copyOf(segments, newCapacity);
+          stateMaps = Arrays.copyOf(stateMaps, newCapacity);
+        }
+        segments[numSegments] = segment;
+        stateMaps[numSegments] = stateMap;
+        numSegments++;
+      }
+      expect(token, JsonToken.END_OBJECT);
+      return ImmutableSortedArrayMap.sortAndWrap(segments, stateMaps, 
numSegments);
+    }
+
+    private Map<String, String> readStateMap(JsonToken token)
+        throws IOException {
+      expect(token, JsonToken.START_OBJECT);
+      int size = 0;
+      while ((token = _parser.nextToken()) == JsonToken.FIELD_NAME) {
+        String instance = instanceName(_parser.currentName());
+        token = _parser.nextToken();
+        String state = token == JsonToken.VALUE_NULL ? null : 
stateValue(token);
+        if (size == _instanceKeys.length) {
+          _instanceKeys = Arrays.copyOf(_instanceKeys, size * 2);
+          _instanceStates = Arrays.copyOf(_instanceStates, size * 2);
+        }
+        _instanceKeys[size] = instance;
+        _instanceStates[size] = state;
+        size++;
+      }
+      expect(token, JsonToken.END_OBJECT);
+      size = sortInPlace(_instanceKeys, _instanceStates, size);
+      return _stateMaps.intern(_instanceKeys, _instanceStates, size);
+    }
+
+    /// Returns the canonical (interned) instance name equal to the given name.
+    private String instanceName(String name) {
+      String canonical = _instanceNames.get(name);
+      if (canonical == null) {
+        canonical = name.intern();
+        _instanceNames.put(canonical, canonical);
+      }
+      return canonical;
+    }
+
+    /// Returns the canonical state for the current value token without 
allocating for the known states.
+    private String stateValue(JsonToken token)
+        throws IOException {
+      if (token != JsonToken.VALUE_STRING) {
+        return scalarText(token).intern();
+      }
+      char[] chars = _parser.getTextCharacters();
+      int offset = _parser.getTextOffset();
+      int length = _parser.getTextLength();
+      for (String knownState : KNOWN_STATES) {
+        if (matches(knownState, chars, offset, length)) {
+          return knownState;
+        }
+      }
+      return new String(chars, offset, length).intern();
+    }
+
+    /// Returns the text of a scalar token. Like Jackson's `Map<String, 
String>` binding, numbers and booleans become
+    /// their text, and objects or arrays fail.
+    private String scalarText(JsonToken token)
+        throws IOException {
+      if (!token.isScalarValue()) {
+        throw new JsonParseException(_parser, "Expected a scalar value but 
got: " + token);
+      }
+      return _parser.getText();
+    }
+
+    private void expect(@Nullable JsonToken actual, JsonToken expected)
+        throws JsonParseException {
+      if (actual != expected) {
+        throw new JsonParseException(_parser, "Expected " + expected + " but 
got: " + actual);
+      }
+    }
+  }
+
+  private static boolean matches(String s, char[] chars, int offset, int 
length) {
+    if (s.length() != length) {
+      return false;
+    }
+    for (int i = 0; i < length; i++) {
+      if (s.charAt(i) != chars[offset + i]) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /// Sorts the first `size` entries of the parallel arrays by key with an 
insertion sort (instance-state maps hold a
+  /// handful of replicas). When a key appears more than once, the last value 
wins. Returns the number of distinct
+  /// keys, which are compacted at the start of the arrays.
+  static int sortInPlace(String[] keys, Object[] values, int size) {
+    int numUnique = 0;
+    for (int i = 0; i < size; i++) {
+      String key = keys[i];
+      Object value = values[i];
+      int low = 0;
+      int high = numUnique - 1;
+      int found = -1;
+      while (low <= high) {
+        int mid = (low + high) >>> 1;
+        int cmp = keys[mid].compareTo(key);
+        if (cmp < 0) {
+          low = mid + 1;
+        } else if (cmp > 0) {
+          high = mid - 1;
+        } else {
+          found = mid;
+          break;
+        }
+      }
+      if (found >= 0) {
+        values[found] = value;
+        continue;
+      }
+      System.arraycopy(keys, low, keys, low + 1, numUnique - low);
+      System.arraycopy(values, low, values, low + 1, numUnique - low);
+      keys[low] = key;
+      values[low] = value;
+      numUnique++;
+    }
+    return numUnique;
+  }
+
+  /// Deduplicates instance-state maps by content within one parse. Keys and 
values passed in are canonical (interned
+  /// or from the known state table), so content equality is reference 
equality element by element.
+  private static final class StateMapTable {
+    private ImmutableSortedArrayMap<?>[] _maps = new 
ImmutableSortedArrayMap<?>[INITIAL_STATE_MAP_TABLE_CAPACITY];
+    private int[] _hashes = new int[INITIAL_STATE_MAP_TABLE_CAPACITY];
+    private int _size;
+
+    @SuppressWarnings("unchecked")
+    Map<String, String> intern(String[] keys, Object[] values, int size) {
+      int hash = 1;
+      for (int i = 0; i < size; i++) {
+        hash = 31 * hash + keys[i].hashCode();
+        hash = 31 * hash + (values[i] == null ? 0 : values[i].hashCode());
+      }
+      int mask = _maps.length - 1;
+      int slot = mix(hash) & mask;
+      while (_maps[slot] != null) {
+        if (_hashes[slot] == hash && sameContent(_maps[slot], keys, values, 
size)) {
+          return (Map<String, String>) _maps[slot];
+        }
+        slot = (slot + 1) & mask;
+      }
+      ImmutableSortedArrayMap<String> map = size == 0 ? 
ImmutableSortedArrayMap.empty()
+          : new ImmutableSortedArrayMap<>(Arrays.copyOf(keys, size), 
Arrays.copyOf(values, size));
+      _maps[slot] = map;
+      _hashes[slot] = hash;
+      if (++_size * 2 > _maps.length) {
+        resize();
+      }
+      return map;
+    }
+
+    private static boolean sameContent(ImmutableSortedArrayMap<?> map, 
String[] keys, Object[] values, int size) {
+      if (map.size() != size) {
+        return false;
+      }
+      for (int i = 0; i < size; i++) {
+        if (map.keyAt(i) != keys[i] || map.valueAt(i) != values[i]) {
+          return false;
+        }
+      }
+      return true;
+    }
+
+    private void resize() {
+      ImmutableSortedArrayMap<?>[] oldMaps = _maps;
+      int[] oldHashes = _hashes;
+      _maps = new ImmutableSortedArrayMap<?>[oldMaps.length * 2];
+      _hashes = new int[oldMaps.length * 2];
+      int mask = _maps.length - 1;
+      for (int i = 0; i < oldMaps.length; i++) {
+        if (oldMaps[i] != null) {
+          int slot = mix(oldHashes[i]) & mask;
+          while (_maps[slot] != null) {
+            slot = (slot + 1) & mask;
+          }
+          _maps[slot] = oldMaps[i];
+          _hashes[slot] = oldHashes[i];
+        }
+      }
+    }
+
+    private static int mix(int hash) {
+      return hash ^ (hash >>> 16);
+    }
+  }
+}
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/HelixHelper.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/HelixHelper.java
index e8636bea991..ac102349d0d 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/HelixHelper.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/HelixHelper.java
@@ -34,6 +34,7 @@ import org.apache.helix.HelixAdmin;
 import org.apache.helix.HelixDataAccessor;
 import org.apache.helix.HelixManager;
 import org.apache.helix.HelixManagerFactory;
+import org.apache.helix.HelixProperty;
 import org.apache.helix.InstanceType;
 import org.apache.helix.PropertyKey.Builder;
 import org.apache.helix.model.ExternalView;
@@ -94,6 +95,46 @@ public class HelixHelper {
         (ZNRecord) 
ZN_RECORD_SERIALIZER.deserialize(ZN_RECORD_SERIALIZER.serialize(idealState.getRecord())));
   }
 
+  /// Returns whether the given record came from the compact parser of 
[CompactZNRecordSerializer], that is, its map
+  /// fields are an [ImmutableSortedArrayMap]. A record from 
[ZNRecordSerializer] returns false, including the records
+  /// that the compact serializer delegates to it (plain znodes and the 
fallback).
+  public static boolean isCompact(ZNRecord record) {
+    return record.getMapFields() instanceof ImmutableSortedArrayMap;
+  }
+
+  /// Returns an [IdealState] for the given record. When the record [is 
compact][#isCompact], the ideal state takes
+  /// over the maps of the record instead of copying them (see `adoptRecord`), 
so the caller must own the record and
+  /// must not use it afterwards. Otherwise this is `new IdealState(record)`.
+  public static IdealState toIdealState(ZNRecord record) {
+    return isCompact(record) ? adoptRecord(record, new 
IdealState(record.getId())) : new IdealState(record);
+  }
+
+  /// Returns an [ExternalView] for the given record. Same contract as 
[#toIdealState].
+  public static ExternalView toExternalView(ZNRecord record) {
+    return isCompact(record) ? adoptRecord(record, new 
ExternalView(record.getId())) : new ExternalView(record);
+  }
+
+  /// Makes the empty record of the given property hold the content of the 
source record, as
+  /// `ZNRecord(ZNRecord, String)` (called by `HelixProperty(ZNRecord)`) does, 
but by reference instead of by copy:
+  /// the simple, list and map fields and the raw payload are shared. The 
scalar fields that the copy constructor
+  /// copies (version, creation time, modified time, ephemeral owner) are 
copied, and the property [HelixProperty.Stat]
+  /// is set from them, as `HelixProperty(ZNRecord)` does. Like the copy 
constructor, the delta list is not copied.
+  /// Returns the given property.
+  private static <T extends HelixProperty> T adoptRecord(ZNRecord source, T 
property) {
+    ZNRecord target = property.getRecord();
+    target.setSimpleFields(source.getSimpleFields());
+    target.setListFields(source.getListFields());
+    target.setMapFields(source.getMapFields());
+    target.setRawPayload(source.getRawPayload());
+    target.setVersion(source.getVersion());
+    target.setCreationTime(source.getCreationTime());
+    target.setModifiedTime(source.getModifiedTime());
+    target.setEphemeralOwner(source.getEphemeralOwner());
+    property.setStat(new HelixProperty.Stat(source.getVersion(), 
source.getCreationTime(), source.getModifiedTime(),
+        source.getEphemeralOwner()));
+    return property;
+  }
+
   public static IdealState updateIdealState(HelixManager helixManager, String 
resourceName,
       Function<IdealState, IdealState> updater) {
     return IDEAL_STATE_GROUP_COMMIT.commit(helixManager, resourceName, updater,
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/ImmutableSortedArrayMap.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/ImmutableSortedArrayMap.java
new file mode 100644
index 00000000000..13056a8a851
--- /dev/null
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/ImmutableSortedArrayMap.java
@@ -0,0 +1,562 @@
+/**
+ * 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.utils.helix;
+
+import java.util.AbstractCollection;
+import java.util.AbstractMap;
+import java.util.AbstractSet;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+
+
+/// An immutable [SortedMap] with [String] keys in natural order, backed by a 
sorted key array and a parallel value
+/// array.
+///
+/// [CompactZNRecordSerializer] uses it for the map fields of ideal states and 
external views: one instance per
+/// distinct instance-state map, and one for the outer segment map. Lookups 
use binary search with an identity fast
+/// path, so interned keys compare cheaply.
+///
+/// The map follows the [Map] and [SortedMap] contracts: [#equals], 
[#hashCode] and [#toString] match those of a
+/// [java.util.TreeMap] with the same content, and [#keySet], [#entrySet] and 
[#values] are [AbstractSet] and
+/// [AbstractCollection] views with the standard `equals` and `hashCode`. 
[#comparator] returns `null`, so
+/// `new TreeMap<>(map)` and `TreeMap.putAll` copy it in linear time. Keys are 
never `null`; values can be `null`.
+///
+/// The values must be immutable too (in practice they are strings or other 
instances of this class). [#hashCode]
+/// is cached, so a value that changes after the map is built makes the cached 
hash code stale.
+///
+/// The map cannot change, but it differs from an unmodifiable 
[java.util.TreeMap] in two ways:
+/// - [#subMap], [#headMap] and [#tailMap] return copies without the range 
bounds of a view. A key outside the range
+///   of the original call is accepted by a further range call: `subMap("b", 
"d").headMap("z")` returns the entries in
+///   `["b", "d")`, where a [java.util.TreeMap] view throws 
[IllegalArgumentException].
+/// - Every mutator that would change the map throws 
[UnsupportedOperationException]. Some mutators inherited by the
+///   views from [AbstractCollection] and [AbstractSet] return normally when 
they have nothing to change, for example
+///   `keySet().addAll(List.of())`, `keySet().removeAll(List.of())` or 
`values().removeIf(v -> false)`.
+///
+/// Thread-safety: the map is immutable and safe to share between threads once 
published. The cached hash codes and
+/// views are computed lazily with benign races: every thread computes the 
same value.
+public final class ImmutableSortedArrayMap<V> extends AbstractMap<String, V> 
implements SortedMap<String, V> {
+  private static final ImmutableSortedArrayMap<?> EMPTY = new 
ImmutableSortedArrayMap<>(new String[0], new Object[0]);
+
+  private final String[] _keys;
+  private final Object[] _values;
+
+  // Lazily computed, benign races. 0 means "not computed yet" (a real hash 
code of 0 is recomputed each time).
+  private int _hashCode;
+  private int _keySetHashCode;
+  private KeySet _keySet;
+  private EntrySet _entrySet;
+  private Values _valuesView;
+
+  /// Wraps the given arrays without copying them. The keys must be non-null, 
distinct and sorted in natural order,
+  /// and the caller must not modify either array afterwards.
+  ImmutableSortedArrayMap(String[] keys, Object[] values) {
+    assert keys.length == values.length;
+    _keys = keys;
+    _values = values;
+  }
+
+  /// Returns the empty map.
+  @SuppressWarnings("unchecked")
+  public static <V> ImmutableSortedArrayMap<V> empty() {
+    return (ImmutableSortedArrayMap<V>) EMPTY;
+  }
+
+  /// Returns an immutable sorted copy of the given map. Keys must be 
non-null, and values must be immutable (see the
+  /// class documentation).
+  static <V> ImmutableSortedArrayMap<V> copyOf(Map<String, ? extends V> map) {
+    if (map instanceof ImmutableSortedArrayMap) {
+      @SuppressWarnings("unchecked")
+      ImmutableSortedArrayMap<V> immutableMap = (ImmutableSortedArrayMap<V>) 
map;
+      return immutableMap;
+    }
+    int size = map.size();
+    if (size == 0) {
+      return empty();
+    }
+    String[] keys = new String[size];
+    Object[] values = new Object[size];
+    int i = 0;
+    for (Map.Entry<String, ? extends V> entry : map.entrySet()) {
+      keys[i] = Objects.requireNonNull(entry.getKey(), "Null key");
+      values[i] = entry.getValue();
+      i++;
+    }
+    return sortAndWrap(keys, values, size);
+  }
+
+  /// Sorts the first `size` entries of the given parallel arrays by key and 
wraps them. When a key appears more than
+  /// once, the last value wins, like repeated [Map#put] calls. The arrays may 
be modified and must not be used by the
+  /// caller afterwards.
+  static <V> ImmutableSortedArrayMap<V> sortAndWrap(String[] keys, Object[] 
values, int size) {
+    if (size == 0) {
+      return empty();
+    }
+    boolean strictlySorted = true;
+    for (int i = 1; i < size; i++) {
+      if (keys[i - 1].compareTo(keys[i]) >= 0) {
+        strictlySorted = false;
+        break;
+      }
+    }
+    if (strictlySorted) {
+      if (keys.length == size) {
+        return new ImmutableSortedArrayMap<>(keys, values);
+      }
+      return new ImmutableSortedArrayMap<>(Arrays.copyOf(keys, size), 
Arrays.copyOf(values, size));
+    }
+    // Stable sort, so the last occurrence of a duplicated key stays the last 
in its run.
+    Integer[] order = new Integer[size];
+    for (int i = 0; i < size; i++) {
+      order[i] = i;
+    }
+    Arrays.sort(order, Comparator.comparing(i -> keys[i]));
+    String[] sortedKeys = new String[size];
+    Object[] sortedValues = new Object[size];
+    int numUnique = 0;
+    for (int i = 0; i < size; i++) {
+      int index = order[i];
+      String key = keys[index];
+      if (numUnique > 0 && sortedKeys[numUnique - 1].equals(key)) {
+        sortedValues[numUnique - 1] = values[index];
+      } else {
+        sortedKeys[numUnique] = key;
+        sortedValues[numUnique] = values[index];
+        numUnique++;
+      }
+    }
+    if (numUnique != size) {
+      sortedKeys = Arrays.copyOf(sortedKeys, numUnique);
+      sortedValues = Arrays.copyOf(sortedValues, numUnique);
+    }
+    return new ImmutableSortedArrayMap<>(sortedKeys, sortedValues);
+  }
+
+  /// Returns the index of the key, or `-(insertionPoint + 1)` when absent.
+  private int indexOf(String key) {
+    int low = 0;
+    int high = _keys.length - 1;
+    while (low <= high) {
+      int mid = (low + high) >>> 1;
+      String midKey = _keys[mid];
+      int cmp = midKey == key ? 0 : midKey.compareTo(key);
+      if (cmp < 0) {
+        low = mid + 1;
+      } else if (cmp > 0) {
+        high = mid - 1;
+      } else {
+        return mid;
+      }
+    }
+    return -(low + 1);
+  }
+
+  /// Returns the index of the first key that is greater than or equal to the 
given key.
+  private int lowerBound(String key) {
+    int index = indexOf(key);
+    return index >= 0 ? index : -(index + 1);
+  }
+
+  /// Returns the key at the given position in sorted order.
+  String keyAt(int index) {
+    return _keys[index];
+  }
+
+  /// Returns the value at the given position in sorted order.
+  @SuppressWarnings("unchecked")
+  V valueAt(int index) {
+    return (V) _values[index];
+  }
+
+  @Override
+  public int size() {
+    return _keys.length;
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return _keys.length == 0;
+  }
+
+  @Override
+  public boolean containsKey(Object key) {
+    return key instanceof String && indexOf((String) key) >= 0;
+  }
+
+  @Override
+  public boolean containsValue(Object value) {
+    for (Object v : _values) {
+      if (v == value || (v != null && v.equals(value))) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Nullable
+  @Override
+  public V get(Object key) {
+    if (!(key instanceof String)) {
+      return null;
+    }
+    int index = indexOf((String) key);
+    return index >= 0 ? valueAt(index) : null;
+  }
+
+  @Override
+  public V getOrDefault(Object key, V defaultValue) {
+    if (!(key instanceof String)) {
+      return defaultValue;
+    }
+    int index = indexOf((String) key);
+    return index >= 0 ? valueAt(index) : defaultValue;
+  }
+
+  @Nullable
+  @Override
+  public Comparator<? super String> comparator() {
+    return null;
+  }
+
+  @Override
+  public String firstKey() {
+    if (_keys.length == 0) {
+      throw new NoSuchElementException();
+    }
+    return _keys[0];
+  }
+
+  @Override
+  public String lastKey() {
+    if (_keys.length == 0) {
+      throw new NoSuchElementException();
+    }
+    return _keys[_keys.length - 1];
+  }
+
+  @Override
+  public SortedMap<String, V> subMap(String fromKey, String toKey) {
+    Objects.requireNonNull(fromKey);
+    Objects.requireNonNull(toKey);
+    if (fromKey.compareTo(toKey) > 0) {
+      throw new IllegalArgumentException("fromKey > toKey");
+    }
+    return range(lowerBound(fromKey), lowerBound(toKey));
+  }
+
+  @Override
+  public SortedMap<String, V> headMap(String toKey) {
+    return range(0, lowerBound(Objects.requireNonNull(toKey)));
+  }
+
+  @Override
+  public SortedMap<String, V> tailMap(String fromKey) {
+    return range(lowerBound(Objects.requireNonNull(fromKey)), _keys.length);
+  }
+
+  /// Returns the entries in `[from, to)`. The result is a copy, not a view: 
it has the same content as a view
+  /// because neither map can change, but it does not keep the range bounds 
(see the class documentation).
+  private SortedMap<String, V> range(int from, int to) {
+    if (from == 0 && to == _keys.length) {
+      return this;
+    }
+    if (from >= to) {
+      return empty();
+    }
+    return new ImmutableSortedArrayMap<>(Arrays.copyOfRange(_keys, from, to), 
Arrays.copyOfRange(_values, from, to));
+  }
+
+  @Override
+  public Set<String> keySet() {
+    KeySet keySet = _keySet;
+    if (keySet == null) {
+      keySet = new KeySet();
+      _keySet = keySet;
+    }
+    return keySet;
+  }
+
+  @Override
+  public Collection<V> values() {
+    Values values = _valuesView;
+    if (values == null) {
+      values = new Values();
+      _valuesView = values;
+    }
+    return values;
+  }
+
+  @Override
+  public Set<Map.Entry<String, V>> entrySet() {
+    EntrySet entrySet = _entrySet;
+    if (entrySet == null) {
+      entrySet = new EntrySet();
+      _entrySet = entrySet;
+    }
+    return entrySet;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o == this) {
+      return true;
+    }
+    if (o instanceof ImmutableSortedArrayMap) {
+      ImmutableSortedArrayMap<?> that = (ImmutableSortedArrayMap<?>) o;
+      return Arrays.equals(_keys, that._keys) && Arrays.equals(_values, 
that._values);
+    }
+    return super.equals(o);
+  }
+
+  @Override
+  public int hashCode() {
+    int hashCode = _hashCode;
+    if (hashCode == 0) {
+      for (int i = 0; i < _keys.length; i++) {
+        hashCode += _keys[i].hashCode() ^ Objects.hashCode(_values[i]);
+      }
+      _hashCode = hashCode;
+    }
+    return hashCode;
+  }
+
+  // Mutators. AbstractMap already throws for put; the remaining ones are 
overridden so that they throw even when
+  // they have nothing to change, and so that no default method runs a 
callback or reaches a mutable code path.
+
+  @Override
+  public V remove(Object key) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void putAll(Map<? extends String, ? extends V> m) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void clear() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void replaceAll(BiFunction<? super String, ? super V, ? extends V> 
function) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public V putIfAbsent(String key, V value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean remove(Object key, Object value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean replace(String key, V oldValue, V newValue) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public V replace(String key, V value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public V computeIfAbsent(String key, Function<? super String, ? extends V> 
mappingFunction) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public V computeIfPresent(String key, BiFunction<? super String, ? super V, 
? extends V> remappingFunction) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public V compute(String key, BiFunction<? super String, ? super V, ? extends 
V> remappingFunction) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public V merge(String key, V value, BiFunction<? super V, ? super V, ? 
extends V> remappingFunction) {
+    throw new UnsupportedOperationException();
+  }
+
+  /// Iterator over the positions of the map. `remove` throws 
[UnsupportedOperationException] (the default).
+  private abstract class ArrayIterator<T> implements Iterator<T> {
+    private int _next;
+
+    @Override
+    public boolean hasNext() {
+      return _next < _keys.length;
+    }
+
+    @Override
+    public T next() {
+      if (_next >= _keys.length) {
+        throw new NoSuchElementException();
+      }
+      return elementAt(_next++);
+    }
+
+    abstract T elementAt(int index);
+  }
+
+  private final class KeySet extends AbstractSet<String> {
+    @Override
+    public Iterator<String> iterator() {
+      return new ArrayIterator<String>() {
+        @Override
+        String elementAt(int index) {
+          return _keys[index];
+        }
+      };
+    }
+
+    @Override
+    public int size() {
+      return _keys.length;
+    }
+
+    @Override
+    public boolean contains(Object o) {
+      return containsKey(o);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o == this) {
+        return true;
+      }
+      if (o instanceof ImmutableSortedArrayMap<?>.KeySet) {
+        return Arrays.equals(_keys, ((ImmutableSortedArrayMap<?>.KeySet) 
o).keys());
+      }
+      return super.equals(o);
+    }
+
+    @Override
+    public int hashCode() {
+      int hashCode = _keySetHashCode;
+      if (hashCode == 0) {
+        for (String key : _keys) {
+          hashCode += key.hashCode();
+        }
+        _keySetHashCode = hashCode;
+      }
+      return hashCode;
+    }
+
+    private String[] keys() {
+      return _keys;
+    }
+
+    @Override
+    public boolean remove(Object o) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void clear() {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private final class Values extends AbstractCollection<V> {
+    @Override
+    public Iterator<V> iterator() {
+      return new ArrayIterator<V>() {
+        @Override
+        V elementAt(int index) {
+          return valueAt(index);
+        }
+      };
+    }
+
+    @Override
+    public int size() {
+      return _keys.length;
+    }
+
+    @Override
+    public boolean contains(Object o) {
+      return containsValue(o);
+    }
+
+    @Override
+    public boolean remove(Object o) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void clear() {
+      throw new UnsupportedOperationException();
+    }
+  }
+
+  private final class EntrySet extends AbstractSet<Map.Entry<String, V>> {
+    @Override
+    public Iterator<Map.Entry<String, V>> iterator() {
+      return new ArrayIterator<Map.Entry<String, V>>() {
+        @Override
+        Map.Entry<String, V> elementAt(int index) {
+          return new AbstractMap.SimpleImmutableEntry<>(_keys[index], 
valueAt(index));
+        }
+      };
+    }
+
+    @Override
+    public int size() {
+      return _keys.length;
+    }
+
+    @Override
+    public boolean contains(Object o) {
+      if (!(o instanceof Map.Entry)) {
+        return false;
+      }
+      Map.Entry<?, ?> entry = (Map.Entry<?, ?>) o;
+      Object key = entry.getKey();
+      if (!(key instanceof String)) {
+        return false;
+      }
+      int index = indexOf((String) key);
+      return index >= 0 && Objects.equals(_values[index], entry.getValue());
+    }
+
+    @Override
+    public boolean remove(Object o) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void clear() {
+      throw new UnsupportedOperationException();
+    }
+  }
+}
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/CompactZNRecordSerializerTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/CompactZNRecordSerializerTest.java
new file mode 100644
index 00000000000..fd747185808
--- /dev/null
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/CompactZNRecordSerializerTest.java
@@ -0,0 +1,360 @@
+/**
+ * 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.utils.helix;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import org.apache.helix.model.ExternalView;
+import org.apache.helix.model.IdealState;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.util.GZipCompressionUtil;
+import org.apache.pinot.spi.utils.CommonConstants;
+import 
org.apache.pinot.spi.utils.CommonConstants.Helix.StateModel.SegmentStateModel;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+
+
+/// Checks that [CompactZNRecordSerializer] returns records equal to those of 
[ZNRecordSerializer], with interned
+/// names, shared instance-state maps and immutable map fields.
+public class CompactZNRecordSerializerTest {
+  private static final ZNRecordSerializer DEFAULT_SERIALIZER = new 
ZNRecordSerializer();
+  private static final CompactZNRecordSerializer COMPACT_SERIALIZER = new 
CompactZNRecordSerializer();
+
+  @DataProvider
+  public Object[][] compression() {
+    return new Object[][]{{false}, {true}};
+  }
+
+  private static byte[] bytes(ZNRecord record, boolean compressed)
+      throws IOException {
+    byte[] bytes = DEFAULT_SERIALIZER.serialize(record);
+    if (compressed && !GZipCompressionUtil.isCompressed(bytes)) {
+      bytes = GZipCompressionUtil.compress(bytes);
+    }
+    assertEquals(GZipCompressionUtil.isCompressed(bytes), compressed);
+    return bytes;
+  }
+
+  private static ZNRecord assertSameAsDefault(byte[] bytes)
+      throws IOException {
+    ZNRecord expected = (ZNRecord) DEFAULT_SERIALIZER.deserialize(bytes);
+    // Call the compact parser directly so that a parse failure cannot hide 
behind the fallback. It only takes
+    // gzip-compressed content.
+    byte[] compressed = GZipCompressionUtil.isCompressed(bytes) ? bytes : 
GZipCompressionUtil.compress(bytes);
+    ZNRecord actual = CompactZNRecordSerializer.deserializeCompact(compressed);
+    // The public deserializer returns the same record for both, plain content 
through the default deserializer
+    assertEquals(COMPACT_SERIALIZER.deserialize(bytes), actual);
+    assertEquals(COMPACT_SERIALIZER.deserialize(compressed), actual);
+    assertNotNull(expected);
+    assertNotNull(actual);
+    assertEquals(actual.getId(), expected.getId());
+    assertEquals(actual, expected);
+    assertEquals(actual.getSimpleFields(), expected.getSimpleFields());
+    assertEquals(actual.getListFields(), expected.getListFields());
+    assertEquals(actual.getMapFields(), expected.getMapFields());
+    assertEquals(actual.getRawPayload(), expected.getRawPayload());
+    // Same iteration order for simple and list fields; map fields are sorted
+    assertEquals(new ArrayList<>(actual.getSimpleFields().keySet()),
+        new ArrayList<>(expected.getSimpleFields().keySet()));
+    assertEquals(new ArrayList<>(actual.getListFields().keySet()), new 
ArrayList<>(expected.getListFields().keySet()));
+    assertEquals(new ArrayList<>(actual.getMapFields().keySet()),
+        new ArrayList<>(new TreeMap<>(expected.getMapFields()).keySet()));
+    return actual;
+  }
+
+  static ZNRecord sampleIdealState() {
+    ZNRecord record = new ZNRecord("myTable_REALTIME");
+    record.setSimpleField("IDEAL_STATE_MODE", "CUSTOMIZED");
+    record.setSimpleField("REBALANCE_MODE", "CUSTOMIZED");
+    record.setSimpleField("HELIX_ENABLED", "true");
+    
record.setSimpleField(CommonConstants.IdealState.HYBRID_TABLE_TIME_BOUNDARY, 
"1700000000000");
+    record.setListField("emptyList", new ArrayList<>());
+    record.setListField("myTable__0__1__20240101T0000Z", new 
ArrayList<>(List.of("Server_b_1", "Server_a_1")));
+    for (int i = 0; i < 50; i++) {
+      Map<String, String> stateMap = new LinkedHashMap<>();
+      // Unsorted on purpose
+      stateMap.put("Server_" + (i % 3 + 5) + "_8098", 
SegmentStateModel.ONLINE);
+      stateMap.put("Server_" + (i % 3 + 1) + "_8098", i % 7 == 0 ? 
SegmentStateModel.CONSUMING : "ONLINE");
+      record.setMapField("myTable__" + (49 - i) + "__0__20240101T0000Z", 
stateMap);
+    }
+    record.setMapField("myTable__unknown", Map.of("Server_1_8098", 
"SOME_NEW_STATE", "Server_2_8098", "ERROR"));
+    record.setMapField("myTable__empty", Map.of());
+    return record;
+  }
+
+  @Test(dataProvider = "compression")
+  public void testRoundTripIdealState(boolean compressed)
+      throws IOException {
+    ZNRecord actual = assertSameAsDefault(bytes(sampleIdealState(), 
compressed));
+
+    // IdealState and ExternalView wrappers work on the compact record
+    IdealState idealState = new IdealState(actual);
+    assertTrue(idealState.isEnabled());
+    
assertEquals(idealState.getRecord().getSimpleField(CommonConstants.IdealState.HYBRID_TABLE_TIME_BOUNDARY),
+        "1700000000000");
+    assertEquals(idealState.getPartitionSet().size(), 52);
+    
assertEquals(idealState.getInstanceStateMap("myTable__unknown").get("Server_1_8098"),
 "SOME_NEW_STATE");
+    ExternalView externalView = new ExternalView(actual);
+    assertEquals(externalView.getStateMap("myTable__empty"), Map.of());
+    assertTrue(externalView.getStateMap("myTable__0__0__20240101T0000Z") 
instanceof SortedMap);
+  }
+
+  @Test(dataProvider = "compression")
+  public void testRoundTripEmptyAndMissingFields(boolean compressed)
+      throws IOException {
+    assertSameAsDefault(bytes(new ZNRecord("empty"), compressed));
+    ZNRecord withPayload = new ZNRecord("payload");
+    withPayload.setRawPayload(new byte[]{1, 2, 3, 4, 5});
+    assertSameAsDefault(bytes(withPayload, compressed));
+
+    // Hand-written JSON: missing fields, unknown properties, null and 
non-string scalar values, duplicate keys
+    String json = "{\"id\":\"t_OFFLINE\",\"unknown\":{\"a\":[1,{\"b\":null}]},"
+        + 
"\"simpleFields\":{\"n\":12,\"b\":true,\"nul\":null,\"dup\":\"1\",\"dup\":\"2\"},"
+        + 
"\"mapFields\":{\"s2\":{\"z\":\"ONLINE\",\"a\":\"OFFLINE\",\"a\":\"ONLINE\"},\"s1\":{\"x\":null,\"y\":7},"
+        + 
"\"s0\":null,\"s2\":{\"q\":\"DROPPED\"}},\"listFields\":{\"l\":[\"x\",null,\"y\"],\"nl\":null}}";
+    byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
+    assertSameAsDefault(bytes);
+    assertSameAsDefault(GZipCompressionUtil.compress(bytes));
+    // Only the id
+    assertSameAsDefault("{\"id\":\"only\"}".getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test(dataProvider = "compression")
+  public void testRoundTripEmptyRawPayload(boolean compressed)
+      throws IOException {
+    // A present but empty payload (base64 of zero bytes) reads back as an 
empty array, not as null
+    ZNRecord record = new ZNRecord("emptyPayload");
+    record.setRawPayload(new byte[0]);
+    byte[] bytes = bytes(record, compressed);
+    String json = new String(DEFAULT_SERIALIZER.serialize(record), 
StandardCharsets.UTF_8);
+    assertTrue(json.replace(" ", "").contains("\"rawPayload\":\"\""), json);
+    ZNRecord actual = assertSameAsDefault(bytes);
+    assertNotNull(actual.getRawPayload());
+    assertEquals(actual.getRawPayload().length, 0);
+  }
+
+  @Test
+  public void testEscapesAndTrailingContent()
+      throws IOException {
+    // JSON escapes in field names, map keys and values (quote, backslash, 
control character, unicode)
+    String json = 
"{\"id\":\"t\\\"1\",\"simpleFields\":{\"k\\\\\":\"v\\n\\u00e9\"},"
+        + 
"\"mapFields\":{\"seg\\u0041\":{\"Server\\\"1\":\"ONLINE\",\"Server_2\":\"ON\\u004cINE\"}},"
+        + "\"listFields\":{\"l\":[\"Server\\\"1\",\"\\u2603\"]}}";
+    byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
+    ZNRecord record = assertSameAsDefault(bytes);
+    assertSameAsDefault(GZipCompressionUtil.compress(bytes));
+    assertEquals(record.getId(), "t\"1");
+    assertEquals(record.getMapField("segA"), Map.of("Server\"1", "ONLINE", 
"Server_2", "ONLINE"));
+    // An escaped known state still maps to the shared constant
+    assertSame(record.getMapField("segA").get("Server_2"), 
SegmentStateModel.ONLINE);
+
+    // Content after the record is ignored, as ObjectMapper.readValue does
+    byte[] trailing = "{\"id\":\"x\",\"mapFields\":{\"s\":{\"a\":\"ONLINE\"}}} 
{\"id\":\"y\"}"
+        .getBytes(StandardCharsets.UTF_8);
+    assertEquals(assertSameAsDefault(trailing).getId(), "x");
+    
assertEquals(assertSameAsDefault(GZipCompressionUtil.compress(trailing)).getId(),
 "x");
+  }
+
+  @Test
+  public void testRoundTripRandomized()
+      throws IOException {
+    Random random = new Random(42);
+    for (int iteration = 0; iteration < 20; iteration++) {
+      ZNRecord record = new ZNRecord("table_" + iteration);
+      int numSegments = random.nextInt(200);
+      for (int i = 0; i < numSegments; i++) {
+        Map<String, String> stateMap = new LinkedHashMap<>();
+        int numReplicas = random.nextInt(5);
+        for (int r = 0; r < numReplicas; r++) {
+          String[] states = {"ONLINE", "OFFLINE", "CONSUMING", "ERROR", 
"OTHER"};
+          stateMap.put("Server_" + random.nextInt(10), 
states[random.nextInt(states.length)]);
+        }
+        record.setMapField("segment_" + random.nextInt(1000), stateMap);
+      }
+      assertSameAsDefault(bytes(record, random.nextBoolean()));
+    }
+  }
+
+  @Test
+  public void testInterningAndSharing()
+      throws IOException {
+    ZNRecord actual = (ZNRecord) 
COMPACT_SERIALIZER.deserialize(bytes(sampleIdealState(), true));
+    Map<String, Map<String, String>> mapFields = actual.getMapFields();
+    assertTrue(mapFields instanceof ImmutableSortedArrayMap);
+    for (Map.Entry<String, Map<String, String>> entry : mapFields.entrySet()) {
+      assertSame(entry.getKey(), entry.getKey().intern());
+      Map<String, String> stateMap = entry.getValue();
+      assertTrue(stateMap instanceof ImmutableSortedArrayMap);
+      for (Map.Entry<String, String> stateEntry : stateMap.entrySet()) {
+        assertSame(stateEntry.getKey(), stateEntry.getKey().intern());
+        assertSame(stateEntry.getValue(), stateEntry.getValue().intern());
+      }
+    }
+    // Segment 49 - i: i = 48 is ONLINE on Server_5, i = 42 is CONSUMING on 
Server_1
+    
assertSame(mapFields.get("myTable__1__0__20240101T0000Z").get("Server_5_8098"), 
SegmentStateModel.ONLINE);
+    
assertSame(mapFields.get("myTable__7__0__20240101T0000Z").get("Server_1_8098"), 
SegmentStateModel.CONSUMING);
+    for (String instance : 
actual.getListField("myTable__0__1__20240101T0000Z")) {
+      assertSame(instance, instance.intern());
+    }
+
+    // Equal instance-state maps share one instance; different ones do not
+    // Segment 49 - i: i = 1 and i = 4 have the same servers and both ONLINE
+    Map<String, String> stateMap48 = 
mapFields.get("myTable__48__0__20240101T0000Z");
+    Map<String, String> stateMap45 = 
mapFields.get("myTable__45__0__20240101T0000Z");
+    assertEquals(stateMap48, stateMap45);
+    assertSame(stateMap48, stateMap45);
+    // i = 0 is CONSUMING on Server_1, i = 3 is ONLINE on Server_1
+    assertNotSame(mapFields.get("myTable__49__0__20240101T0000Z"), 
mapFields.get("myTable__46__0__20240101T0000Z"));
+
+    // Map fields are immutable, scalar fields are not
+    assertThrows(UnsupportedOperationException.class, () -> 
actual.setMapField("x", Map.of()));
+    assertThrows(UnsupportedOperationException.class, () -> 
stateMap48.put("x", "ONLINE"));
+    actual.setVersion(7);
+    actual.setCreationTime(1L);
+    actual.setModifiedTime(2L);
+    actual.setEphemeralOwner(3L);
+    actual.setSimpleField("new", "value");
+    assertEquals(actual.getVersion(), 7);
+
+    // HelixProperty copies the outer map into a TreeMap and keeps the inner 
maps
+    IdealState idealState = new IdealState(actual);
+    
assertSame(idealState.getInstanceStateMap("myTable__48__0__20240101T0000Z"), 
stateMap48);
+    assertEquals(idealState.getRecord().getMapFields(), mapFields);
+  }
+
+  @Test
+  public void testSortInPlace() {
+    String[] keys = {"c", "a", "b", "a", "d", null};
+    Object[] values = {"1", "2", "3", "4", "5", null};
+    int size = CompactZNRecordSerializer.sortInPlace(keys, values, 5);
+    assertEquals(size, 4);
+    assertEquals(Arrays.copyOf(keys, size), new String[]{"a", "b", "c", "d"});
+    assertEquals(Arrays.copyOf(values, size), new Object[]{"4", "3", "1", 
"5"});
+  }
+
+  @Test
+  public void testMalformedInputFallsBack()
+      throws IOException {
+    // Both deserializers return null for malformed JSON
+    byte[] malformed = 
"{\"id\":\"x\",\"mapFields\":{\"s\":[1]}}".getBytes(StandardCharsets.UTF_8);
+    assertNull(DEFAULT_SERIALIZER.deserialize(malformed));
+    assertNull(COMPACT_SERIALIZER.deserialize(malformed));
+    // Gzip-compressed: the compact parser fails and falls back to the default 
deserializer
+    byte[] compressedMalformed = GZipCompressionUtil.compress(malformed);
+    assertThrows(IOException.class, () -> 
CompactZNRecordSerializer.deserializeCompact(compressedMalformed));
+    assertNull(DEFAULT_SERIALIZER.deserialize(compressedMalformed));
+    assertNull(COMPACT_SERIALIZER.deserialize(compressedMalformed));
+    assertNull(COMPACT_SERIALIZER.deserialize("not 
json".getBytes(StandardCharsets.UTF_8)));
+    assertNull(COMPACT_SERIALIZER.deserialize(new byte[0]));
+    assertNull(COMPACT_SERIALIZER.deserialize(null));
+  }
+
+  @Test
+  public void testUnsupportedInputFallsBackToValidRecord()
+      throws IOException {
+    // The default deserializer reads a byte array written as a JSON array of 
numbers; the compact parser only reads
+    // base64 text. The compact parser fails, and the fallback returns the 
record of the default deserializer.
+    byte[] bytes = GZipCompressionUtil.compress(
+        
"{\"id\":\"x\",\"mapFields\":{\"s\":{\"a\":\"ONLINE\"}},\"rawPayload\":[1,2,3]}"
+            .getBytes(StandardCharsets.UTF_8));
+    assertThrows(IOException.class, () -> 
CompactZNRecordSerializer.deserializeCompact(bytes));
+    ZNRecord expected = (ZNRecord) DEFAULT_SERIALIZER.deserialize(bytes);
+    assertNotNull(expected);
+    assertEquals(expected.getRawPayload(), new byte[]{1, 2, 3});
+    ZNRecord actual = (ZNRecord) COMPACT_SERIALIZER.deserialize(bytes);
+    assertNotNull(actual);
+    assertEquals(actual, expected);
+    assertEquals(actual.getMapFields(), expected.getMapFields());
+    assertEquals(actual.getRawPayload(), expected.getRawPayload());
+    assertFalse(HelixHelper.isCompact(actual));
+  }
+
+  @Test
+  public void testCorruptGzipTrailerFallsBack()
+      throws IOException {
+    byte[] bytes = bytes(sampleIdealState(), true);
+    assertNotNull(CompactZNRecordSerializer.deserializeCompact(bytes));
+    // The gzip trailer is the CRC-32 (4 bytes) and the uncompressed size (4 
bytes). The deflate data stays valid.
+    byte[] badCrc = bytes.clone();
+    badCrc[badCrc.length - 8] ^= 0x01;
+    assertThrows(IOException.class, () -> 
CompactZNRecordSerializer.deserializeCompact(badCrc));
+    assertNull(DEFAULT_SERIALIZER.deserialize(badCrc));
+    assertNull(COMPACT_SERIALIZER.deserialize(badCrc));
+    byte[] badSize = bytes.clone();
+    badSize[badSize.length - 1] ^= 0x01;
+    assertThrows(IOException.class, () -> 
CompactZNRecordSerializer.deserializeCompact(badSize));
+    assertNull(COMPACT_SERIALIZER.deserialize(badSize));
+  }
+
+  @Test(dataProvider = "compression")
+  public void testOnlyGzipUsesCompactParser(boolean compressed)
+      throws IOException {
+    byte[] bytes = bytes(sampleIdealState(), compressed);
+    ZNRecord expected = (ZNRecord) DEFAULT_SERIALIZER.deserialize(bytes);
+    ZNRecord actual = (ZNRecord) COMPACT_SERIALIZER.deserialize(bytes);
+    assertNotNull(actual);
+    assertEquals(actual, expected);
+    assertEquals(actual.getMapFields(), expected.getMapFields());
+    assertEquals(HelixHelper.isCompact(actual), compressed);
+    Map<String, String> stateMap = 
actual.getMapField("myTable__0__0__20240101T0000Z");
+    if (compressed) {
+      assertTrue(actual.getMapFields() instanceof ImmutableSortedArrayMap);
+      assertTrue(stateMap instanceof ImmutableSortedArrayMap);
+    } else {
+      // A plain znode goes to the default deserializer: same map types, 
mutable maps
+      assertEquals(actual.getMapFields().getClass(), 
expected.getMapFields().getClass());
+      assertEquals(stateMap.getClass(), 
expected.getMapField("myTable__0__0__20240101T0000Z").getClass());
+      assertFalse(stateMap instanceof ImmutableSortedArrayMap);
+      stateMap.put("Server_new_8098", SegmentStateModel.ONLINE);
+      actual.setMapField("newSegment", new TreeMap<>());
+      assertEquals(actual.getMapFields().size(), 
expected.getMapFields().size() + 1);
+    }
+  }
+
+  @Test
+  public void testSerializeDelegates()
+      throws IOException {
+    ZNRecord record = sampleIdealState();
+    byte[] expected = DEFAULT_SERIALIZER.serialize(record);
+    assertEquals(COMPACT_SERIALIZER.serialize(record), expected);
+    // A compact record serializes to the same bytes as an equal record from 
the default deserializer
+    ZNRecord compact = 
CompactZNRecordSerializer.deserializeCompact(GZipCompressionUtil.compress(expected));
+    ZNRecord standard = (ZNRecord) DEFAULT_SERIALIZER.deserialize(expected);
+    assertTrue(HelixHelper.isCompact(compact));
+    
assertEquals(DEFAULT_SERIALIZER.deserialize(COMPACT_SERIALIZER.serialize(compact)),
 standard);
+  }
+}
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/HelixHelperTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/HelixHelperTest.java
index cef05ccfb1b..dd84cea5143 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/HelixHelperTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/HelixHelperTest.java
@@ -18,18 +18,32 @@
  */
 package org.apache.pinot.common.utils.helix;
 
+import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
+import java.util.TreeMap;
+import org.apache.helix.HelixProperty;
+import org.apache.helix.model.ExternalView;
+import org.apache.helix.model.IdealState;
 import org.apache.helix.model.InstanceConfig;
+import org.apache.helix.zookeeper.datamodel.ZNRecord;
+import org.apache.helix.zookeeper.datamodel.serializer.ZNRecordSerializer;
+import org.apache.helix.zookeeper.util.GZipCompressionUtil;
+import org.apache.pinot.spi.utils.CommonConstants;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNotSame;
 import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
 
 
 public class HelixHelperTest {
+  private static final ZNRecordSerializer DEFAULT_SERIALIZER = new 
ZNRecordSerializer();
 
   @Test
   public void testUpdateHostName() {
@@ -88,4 +102,93 @@ public class HelixHelperTest {
     assertTrue(HelixHelper.removeDisabledPartitions(instanceConfig));
     assertTrue(instanceConfig.getDisabledPartitionsMap().isEmpty());
   }
+
+  @Test
+  public void testToIdealStateAndExternalViewShareCompactRecord()
+      throws IOException {
+    ZNRecord written = CompactZNRecordSerializerTest.sampleIdealState();
+    written.setSimpleField("HELIX_ENABLED", "false");
+    written.setRawPayload(new byte[]{1, 2, 3});
+    // The compact parser only takes gzip-compressed content
+    byte[] bytes = 
GZipCompressionUtil.compress(DEFAULT_SERIALIZER.serialize(written));
+
+    for (boolean externalView : new boolean[]{false, true}) {
+      ZNRecord compact = CompactZNRecordSerializer.deserializeCompact(bytes);
+      setScalars(compact);
+      assertTrue(HelixHelper.isCompact(compact));
+      ZNRecord copied = (ZNRecord) DEFAULT_SERIALIZER.deserialize(bytes);
+      setScalars(copied);
+      assertFalse(HelixHelper.isCompact(copied));
+      HelixProperty expected = externalView ? new ExternalView(copied) : new 
IdealState(copied);
+
+      HelixProperty actual = externalView ? HelixHelper.toExternalView(compact)
+          : HelixHelper.toIdealState(compact);
+      assertTrue(externalView ? actual instanceof ExternalView : actual 
instanceof IdealState);
+      ZNRecord record = actual.getRecord();
+      // No copy: the maps and the payload are the ones of the parsed record
+      assertSame(record.getMapFields(), compact.getMapFields());
+      assertSame(record.getSimpleFields(), compact.getSimpleFields());
+      assertSame(record.getListFields(), compact.getListFields());
+      assertSame(record.getRawPayload(), compact.getRawPayload());
+      // Same content, scalar fields and stat as the copying constructor
+      assertEquals(actual.getId(), expected.getId());
+      assertEquals(record, expected.getRecord());
+      assertEquals(record.getSimpleFields(), 
expected.getRecord().getSimpleFields());
+      assertEquals(record.getListFields(), 
expected.getRecord().getListFields());
+      assertEquals(record.getMapFields(), expected.getRecord().getMapFields());
+      assertEquals(record.getRawPayload(), 
expected.getRecord().getRawPayload());
+      assertEquals(record.getVersion(), 17);
+      assertEquals(record.getCreationTime(), 1000L);
+      assertEquals(record.getModifiedTime(), 2000L);
+      assertEquals(record.getEphemeralOwner(), 3000L);
+      assertEquals(actual.getStat(), expected.getStat());
+      assertEquals(actual.getStat().getVersion(), 17);
+      assertEquals(actual.getBucketSize(), expected.getBucketSize());
+      if (externalView) {
+        assertEquals(((ExternalView) actual).getPartitionSet(), 
((ExternalView) expected).getPartitionSet());
+        assertSame(((ExternalView) 
actual).getStateMap("myTable__0__0__20240101T0000Z"),
+            compact.getMapField("myTable__0__0__20240101T0000Z"));
+      } else {
+        IdealState idealState = (IdealState) actual;
+        assertFalse(idealState.isEnabled());
+        assertEquals(idealState.isEnabled(), ((IdealState) 
expected).isEnabled());
+        assertEquals(idealState.getRebalanceMode(), 
IdealState.RebalanceMode.CUSTOMIZED);
+        assertEquals(idealState.getPartitionSet(), ((IdealState) 
expected).getPartitionSet());
+        
assertEquals(idealState.getRecord().getSimpleField(CommonConstants.IdealState.HYBRID_TABLE_TIME_BOUNDARY),
+            "1700000000000");
+      }
+      // The shared outer map stays immutable
+      assertThrows(UnsupportedOperationException.class, () -> 
record.getMapFields().remove("myTable__empty"));
+    }
+  }
+
+  @Test
+  public void testToIdealStateAndExternalViewCopyDefaultRecord() {
+    byte[] bytes = 
DEFAULT_SERIALIZER.serialize(CompactZNRecordSerializerTest.sampleIdealState());
+    for (boolean externalView : new boolean[]{false, true}) {
+      // A record from the default deserializer (or from the fallback) goes 
through the copying constructor
+      ZNRecord standard = (ZNRecord) DEFAULT_SERIALIZER.deserialize(bytes);
+      assertNotNull(standard);
+      setScalars(standard);
+      assertFalse(HelixHelper.isCompact(standard));
+      HelixProperty actual = externalView ? 
HelixHelper.toExternalView(standard)
+          : HelixHelper.toIdealState(standard);
+      HelixProperty expected = externalView ? new ExternalView(standard) : new 
IdealState(standard);
+      assertNotSame(actual.getRecord(), standard);
+      assertNotSame(actual.getRecord().getMapFields(), 
standard.getMapFields());
+      assertTrue(actual.getRecord().getMapFields() instanceof TreeMap);
+      assertEquals(actual.getRecord(), expected.getRecord());
+      assertEquals(actual.getStat(), expected.getStat());
+      // The copy is mutable, as today
+      actual.getRecord().getMapFields().remove("myTable__empty");
+      assertTrue(standard.getMapFields().containsKey("myTable__empty"));
+    }
+  }
+
+  private static void setScalars(ZNRecord record) {
+    record.setVersion(17);
+    record.setCreationTime(1000L);
+    record.setModifiedTime(2000L);
+    record.setEphemeralOwner(3000L);
+  }
 }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/ImmutableSortedArrayMapTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/ImmutableSortedArrayMapTest.java
new file mode 100644
index 00000000000..83bc7a6b12c
--- /dev/null
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/helix/ImmutableSortedArrayMapTest.java
@@ -0,0 +1,207 @@
+/**
+ * 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.utils.helix;
+
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+
+
+/// Checks that [ImmutableSortedArrayMap] behaves like a [TreeMap] for reads 
and rejects every mutation.
+public class ImmutableSortedArrayMapTest {
+
+  private static Map<String, String> unsortedSample() {
+    Map<String, String> map = new LinkedHashMap<>();
+    map.put("Server_c_8098", "ONLINE");
+    map.put("Server_a_8098", "CONSUMING");
+    map.put("Server_b_8098", "OFFLINE");
+    map.put("Server_d_8098", null);
+    return map;
+  }
+
+  @Test
+  public void testReadsMatchTreeMap() {
+    Map<String, String> source = unsortedSample();
+    TreeMap<String, String> expected = new TreeMap<>(source);
+    ImmutableSortedArrayMap<String> map = 
ImmutableSortedArrayMap.copyOf(source);
+
+    assertEquals(map.size(), expected.size());
+    assertFalse(map.isEmpty());
+    assertNull(map.comparator());
+    assertEquals(new ArrayList<>(map.keySet()), new 
ArrayList<>(expected.keySet()));
+    assertEquals(new ArrayList<>(map.values()), new 
ArrayList<>(expected.values()));
+    assertEquals(new ArrayList<>(map.entrySet()), new 
ArrayList<>(expected.entrySet()));
+    assertEquals(map.toString(), expected.toString());
+    assertEquals(map.firstKey(), expected.firstKey());
+    assertEquals(map.lastKey(), expected.lastKey());
+    for (String key : List.of("Server_a_8098", "Server_d_8098", 
"Server_e_8098", "")) {
+      assertEquals(map.get(key), expected.get(key), key);
+      assertEquals(map.containsKey(key), expected.containsKey(key), key);
+      assertEquals(map.getOrDefault(key, "x"), expected.getOrDefault(key, 
"x"), key);
+    }
+    // Lookups with a non-interned equal key and with keys of another type
+    assertEquals(map.get(new String("Server_b_8098")), "OFFLINE");
+    assertNull(map.get(42));
+    assertFalse(map.containsKey(null));
+    assertNull(map.get(null));
+    assertTrue(map.containsValue("ONLINE"));
+    assertTrue(map.containsValue(null));
+    assertFalse(map.containsValue("ERROR"));
+    assertFalse(map.containsValue(42));
+    assertTrue(map.entrySet().contains(new 
AbstractMap.SimpleEntry<>("Server_a_8098", "CONSUMING")));
+    assertFalse(map.entrySet().contains(new 
AbstractMap.SimpleEntry<>("Server_a_8098", "ONLINE")));
+    assertTrue(map.values().contains(null));
+  }
+
+  @Test
+  public void testEqualsAndHashCodeParity() {
+    Map<String, String> source = unsortedSample();
+    TreeMap<String, String> treeMap = new TreeMap<>(source);
+    HashMap<String, String> hashMap = new HashMap<>(source);
+    ImmutableSortedArrayMap<String> map = 
ImmutableSortedArrayMap.copyOf(source);
+
+    assertEquals(map, treeMap);
+    assertEquals(treeMap, map);
+    assertEquals(map, hashMap);
+    assertEquals(hashMap, map);
+    assertEquals(map.hashCode(), treeMap.hashCode());
+    assertEquals(map.hashCode(), hashMap.hashCode());
+    assertEquals(map, ImmutableSortedArrayMap.copyOf(hashMap));
+
+    Set<String> hashSet = new HashSet<>(source.keySet());
+    assertEquals(map.keySet(), hashSet);
+    assertEquals(hashSet, map.keySet());
+    assertEquals(map.keySet(), treeMap.keySet());
+    assertEquals(map.keySet().hashCode(), hashSet.hashCode());
+    assertEquals(map.entrySet(), treeMap.entrySet());
+    assertEquals(treeMap.entrySet(), map.entrySet());
+    assertEquals(map.entrySet().hashCode(), treeMap.entrySet().hashCode());
+
+    // A HashMap keyed by key sets works across implementations, as in 
ReplicaGroupInstanceSelector
+    Map<Set<String>, String> byKeySet = new HashMap<>();
+    byKeySet.put(treeMap.keySet(), "value");
+    assertEquals(byKeySet.get(map.keySet()), "value");
+    byKeySet.clear();
+    byKeySet.put(map.keySet(), "value");
+    assertEquals(byKeySet.get(hashSet), "value");
+
+    Map<String, String> different = new TreeMap<>(source);
+    different.put("Server_a_8098", "ONLINE");
+    assertFalse(map.equals(different));
+    assertFalse(different.equals(map));
+    assertFalse(map.equals(ImmutableSortedArrayMap.copyOf(different)));
+  }
+
+  @Test
+  public void testSortedViews() {
+    TreeMap<String, String> expected = new TreeMap<>(unsortedSample());
+    ImmutableSortedArrayMap<String> map = 
ImmutableSortedArrayMap.copyOf(expected);
+    for (String from : List.of("", "Server_a_8098", "Server_b", 
"Server_c_8098", "Z")) {
+      assertEquals(map.tailMap(from), expected.tailMap(from), from);
+      assertEquals(map.headMap(from), expected.headMap(from), from);
+      for (String to : List.of("Server_b", "Server_c_8098", "Z")) {
+        if (from.compareTo(to) <= 0) {
+          SortedMap<String, String> subMap = map.subMap(from, to);
+          assertEquals(subMap, expected.subMap(from, to), from + "-" + to);
+          assertEquals(new ArrayList<>(subMap.keySet()), new 
ArrayList<>(expected.subMap(from, to).keySet()));
+        }
+      }
+    }
+    assertThrows(IllegalArgumentException.class, () -> map.subMap("b", "a"));
+    assertThrows(NullPointerException.class, () -> map.headMap(null));
+    assertThrows(NoSuchElementException.class, () -> 
ImmutableSortedArrayMap.empty().firstKey());
+    assertThrows(NoSuchElementException.class, () -> 
ImmutableSortedArrayMap.empty().lastKey());
+  }
+
+  @Test
+  public void testDuplicateKeysLastWins() {
+    String[] keys = {"b", "a", "b", "c", "a"};
+    Object[] values = {"1", "2", "3", "4", "5"};
+    ImmutableSortedArrayMap<String> map = 
ImmutableSortedArrayMap.sortAndWrap(keys, values, keys.length);
+    assertEquals(map, Map.of("a", "5", "b", "3", "c", "4"));
+    assertEquals(new ArrayList<>(map.keySet()), List.of("a", "b", "c"));
+
+    // Sorted input with a trailing capacity is trimmed
+    String[] sortedKeys = {"a", "b", null, null};
+    Object[] sortedValues = {"1", "2", null, null};
+    assertEquals(ImmutableSortedArrayMap.sortAndWrap(sortedKeys, sortedValues, 
2), Map.of("a", "1", "b", "2"));
+    assertSame(ImmutableSortedArrayMap.sortAndWrap(new String[0], new 
Object[0], 0), ImmutableSortedArrayMap.empty());
+  }
+
+  @Test
+  public void testMutatorsThrow() {
+    ImmutableSortedArrayMap<String> map = 
ImmutableSortedArrayMap.copyOf(unsortedSample());
+    assertThrows(UnsupportedOperationException.class, () -> map.put("x", "y"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.remove("Server_a_8098"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.remove("Server_a_8098", "CONSUMING"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.putAll(Map.of("x", "y")));
+    assertThrows(UnsupportedOperationException.class, map::clear);
+    assertThrows(UnsupportedOperationException.class, () -> 
map.putIfAbsent("x", "y"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.replace("Server_a_8098", "y"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.replace("Server_a_8098", "CONSUMING", "y"));
+    assertThrows(UnsupportedOperationException.class, () -> map.replaceAll((k, 
v) -> v));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.computeIfAbsent("x", k -> "y"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.computeIfPresent("Server_a_8098", (k, v) -> v));
+    assertThrows(UnsupportedOperationException.class, () -> map.compute("x", 
(k, v) -> v));
+    assertThrows(UnsupportedOperationException.class, () -> map.merge("x", 
"y", (a, b) -> a));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.keySet().remove("Server_a_8098"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.keySet().add("x"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.keySet().clear());
+    assertThrows(UnsupportedOperationException.class, () -> 
map.keySet().retainAll(Set.of()));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.values().remove("ONLINE"));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.values().clear());
+    assertThrows(UnsupportedOperationException.class, () -> 
map.entrySet().clear());
+    assertThrows(UnsupportedOperationException.class,
+        () -> map.entrySet().remove(new 
AbstractMap.SimpleEntry<>("Server_a_8098", "CONSUMING")));
+    assertThrows(UnsupportedOperationException.class, () -> 
map.entrySet().iterator().next().setValue("x"));
+    Iterator<String> iterator = map.keySet().iterator();
+    iterator.next();
+    assertThrows(UnsupportedOperationException.class, iterator::remove);
+    assertEquals(map, new TreeMap<>(unsortedSample()));
+  }
+
+  @Test
+  public void testTreeMapCopyCompatibility() {
+    // TreeMap takes the buildFromSorted path for a SortedMap with the same 
(natural) comparator, so the copy made by
+    // HelixProperty does not compare keys; comparator() returning null 
enables that path. This test checks only the
+    // content and the order of the copy.
+    ImmutableSortedArrayMap<String> map = 
ImmutableSortedArrayMap.copyOf(unsortedSample());
+    TreeMap<String, String> copy = new TreeMap<>();
+    copy.putAll(map);
+    assertEquals(copy, map);
+    assertEquals(new ArrayList<>(copy.keySet()), new 
ArrayList<>(map.keySet()));
+  }
+}
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
index 77a089e0908..967ab1b8dac 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java
@@ -611,6 +611,14 @@ public class CommonConstants {
     public static final int 
DEFAULT_ROUTING_ASSIGNMENT_CHANGE_PROCESS_PARALLELISM =
         Runtime.getRuntime().availableProcessors();
 
+    /// When enabled, the broker's spectator Helix managers read table ideal 
states and external views with a
+    /// compact streaming deserializer. It decompresses and parses the znode 
in one pass, interns instance and
+    /// segment names, and shares identical instance-state maps. The broker 
resource keeps the default Helix
+    /// deserializer. Takes effect on broker start.
+    public static final String 
CONFIG_OF_ROUTING_COMPACT_ASSIGNMENT_READER_ENABLED =
+        "pinot.broker.routing.compact.assignment.reader.enabled";
+    public static final boolean 
DEFAULT_ROUTING_COMPACT_ASSIGNMENT_READER_ENABLED = false;
+
     // When enabled, the broker will set a query option to ignore 
SERVER_SEGMENT_MISSING errors from servers.
     // This is useful to tolerate short windows where routing has not yet 
reflected recently deleted segments.
     public static final String CONFIG_OF_IGNORE_MISSING_SEGMENTS =


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

Reply via email to