This is an automated email from the ASF dual-hosted git repository.
Jackie-Jiang 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 0d624f598f2 Clone IdealState without a serialization round trip
(#19738)
0d624f598f2 is described below
commit 0d624f598f236994c784737dfa6d29f609f21e85
Author: Suvodeep Pyne <[email protected]>
AuthorDate: Fri Oct 16 13:07:53 2026 -0700
Clone IdealState without a serialization round trip (#19738)
---
.../pinot/common/utils/helix/HelixHelper.java | 35 +++++--
.../common/utils/helix/IdealStateSingleCommit.java | 9 +-
.../pinot/common/utils/helix/HelixHelperTest.java | 113 +++++++++++++++++++++
3 files changed, 143 insertions(+), 14 deletions(-)
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 ac102349d0d..6517ffe002b 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
@@ -21,10 +21,12 @@ package org.apache.pinot.common.utils.helix;
import com.google.common.base.Preconditions;
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 java.util.TreeMap;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@@ -45,7 +47,6 @@ import org.apache.helix.model.InstanceConfig;
import org.apache.helix.model.builder.HelixConfigScopeBuilder;
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.pinot.common.helix.ExtraInstanceConfig;
import org.apache.pinot.common.metadata.ZKMetadataProvider;
import org.apache.pinot.common.utils.config.TagNameUtils;
@@ -73,7 +74,6 @@ public class HelixHelper {
RetryPolicies.randomDelayRetryPolicy(20, 100L, 200L);
private static final Logger LOGGER =
LoggerFactory.getLogger(HelixHelper.class);
- private static final ZNRecordSerializer ZN_RECORD_SERIALIZER = new
ZNRecordSerializer();
private static final IdealStateGroupCommit IDEAL_STATE_GROUP_COMMIT = new
IdealStateGroupCommit();
private static final String ONLINE = "ONLINE";
@@ -90,14 +90,37 @@ public class HelixHelper {
return HelixManagerFactory.getZKHelixManager(clusterName, instanceName,
instanceType, zkAddress);
}
+ /// Returns a deep copy of the IdealState with the same content as cloning
it through a serialization round trip of
+ /// its ZNRecord: id, simple fields, map fields, list fields and raw
payload, with the same container types and
+ /// iteration order. Like the round trip, the ZK stat fields (e.g. version)
are not copied. Unlike the round trip, the
+ /// serializer's write policies (list field bound, size limit) are not
applied; they still apply when the IdealState
+ /// is written.
+ ///
+ /// Copying the fields directly avoids serializing (and compressing) the
whole ZNRecord and parsing it back, which
+ /// takes seconds for an IdealState with hundreds of thousands of segments
and widens the window for version
+ /// conflicts when updating it.
public static IdealState cloneIdealState(IdealState idealState) {
- return new IdealState(
- (ZNRecord)
ZN_RECORD_SERIALIZER.deserialize(ZN_RECORD_SERIALIZER.serialize(idealState.getRecord())));
+ ZNRecord record = idealState.getRecord();
+ IdealState copy = new IdealState(record.getId());
+ ZNRecord copyRecord = copy.getRecord();
+ copyRecord.setSimpleFields(new TreeMap<>(record.getSimpleFields()));
+ TreeMap<String, Map<String, String>> mapFields = new
TreeMap<>(record.getMapFields());
+ mapFields.replaceAll((key, value) -> value != null ? new
LinkedHashMap<>(value) : null);
+ copyRecord.setMapFields(mapFields);
+ TreeMap<String, List<String>> listFields = new
TreeMap<>(record.getListFields());
+ listFields.replaceAll((key, value) -> value != null ? new
ArrayList<>(value) : null);
+ copyRecord.setListFields(listFields);
+ byte[] rawPayload = record.getRawPayload();
+ if (rawPayload != null) {
+ copyRecord.setRawPayload(rawPayload.clone());
+ }
+ return copy;
}
/// 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).
+ /// fields are an [ImmutableSortedArrayMap]. A record from
+ ///
[ZNRecordSerializer][org.apache.helix.zookeeper.datamodel.serializer.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;
}
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/IdealStateSingleCommit.java
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/IdealStateSingleCommit.java
index b6eced13267..e7c9b361bb1 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/IdealStateSingleCommit.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/utils/helix/IdealStateSingleCommit.java
@@ -27,7 +27,6 @@ import org.apache.helix.HelixManager;
import org.apache.helix.PropertyKey;
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.zkclient.exception.ZkBadVersionException;
import org.apache.pinot.common.metrics.ControllerMeter;
import org.apache.pinot.common.metrics.ControllerMetrics;
@@ -42,7 +41,6 @@ public class IdealStateSingleCommit {
private static final Logger LOGGER =
LoggerFactory.getLogger(IdealStateSingleCommit.class);
private static final String ENABLE_COMPRESSIONS_KEY = "enableCompression";
private static final int NUM_PARTITIONS_THRESHOLD_TO_ENABLE_COMPRESSION =
1000;
- private static final ZNRecordSerializer ZN_RECORD_SERIALIZER = new
ZNRecordSerializer();
private IdealStateSingleCommit() {
}
@@ -64,7 +62,7 @@ public class IdealStateSingleCommit {
// Make a copy of the idealState above to pass it to the updater
// NOTE: new IdealState(idealState.getRecord()) does not work
because it's shallow copy for map fields and
// list fields
- IdealState idealStateCopy = cloneIdealState(idealState);
+ IdealState idealStateCopy = HelixHelper.cloneIdealState(idealState);
IdealState updatedIdealState;
try {
@@ -149,9 +147,4 @@ public class IdealStateSingleCommit {
private static class IdealStateWrapper {
IdealState _idealState;
}
-
- public static IdealState cloneIdealState(IdealState idealState) {
- return new IdealState(
- (ZNRecord)
ZN_RECORD_SERIALIZER.deserialize(ZN_RECORD_SERIALIZER.serialize(idealState.getRecord())));
- }
}
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 dd84cea5143..da248b03ece 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,10 +18,21 @@
*/
package org.apache.pinot.common.utils.helix;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import java.util.TreeMap;
+import java.util.stream.Collectors;
import org.apache.helix.HelixProperty;
import org.apache.helix.model.ExternalView;
import org.apache.helix.model.IdealState;
@@ -30,6 +41,7 @@ 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.JsonUtils;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
@@ -191,4 +203,105 @@ public class HelixHelperTest {
record.setModifiedTime(2000L);
record.setEphemeralOwner(3000L);
}
+
+ @Test
+ public void testCloneIdealState() {
+ IdealState idealState = new IdealState("myTable_REALTIME");
+ ZNRecord record = idealState.getRecord();
+ record.setSimpleField("REPLICAS", "2");
+ record.setBooleanField("enableCompression", true);
+ // Insertion order that differs from the sorted order, as in a
deserialized ZNRecord
+ Map<String, Map<String, String>> mapFields = new LinkedHashMap<>();
+ Map<String, String> instanceStateMap = new LinkedHashMap<>();
+ instanceStateMap.put("Server_2", "CONSUMING");
+ instanceStateMap.put("Server_1", "CONSUMING");
+ mapFields.put("segment_2", instanceStateMap);
+ mapFields.put("segment_1", new LinkedHashMap<>(Map.of("Server_1",
"ONLINE")));
+ record.setMapFields(mapFields);
+ record.setListField("segment_2", new ArrayList<>(List.of("Server_2",
"Server_1")));
+ record.setRawPayload(new byte[]{1, 2, 3});
+ record.setVersion(5);
+
+ // Expected result of cloning through a serialization round trip
+ ZNRecordSerializer serializer = new ZNRecordSerializer();
+ ZNRecord expected =
+ new IdealState((ZNRecord)
serializer.deserialize(serializer.serialize(record))).getRecord();
+ ZNRecord copy = HelixHelper.cloneIdealState(idealState).getRecord();
+
+ // Same content and iteration order
+ assertEquals(copy.getId(), expected.getId());
+ assertEquals(copy, expected);
+ assertEquals(new ArrayList<>(copy.getMapFields().keySet()), new
ArrayList<>(expected.getMapFields().keySet()));
+ assertEquals(new ArrayList<>(copy.getMapField("segment_2").keySet()),
+ new ArrayList<>(expected.getMapField("segment_2").keySet()));
+ assertEquals(copy.getRawPayload(), expected.getRawPayload());
+ assertEquals(copy.getVersion(), expected.getVersion());
+
+ // Modifying the copy does not modify the original
+ assertNotSame(copy.getRawPayload(), record.getRawPayload());
+ copy.getSimpleFields().put("REPLICAS", "3");
+ copy.getMapField("segment_2").put("Server_2", "ONLINE");
+ copy.getMapFields().remove("segment_1");
+ copy.getListField("segment_2").add("Server_3");
+ assertEquals(record.getSimpleField("REPLICAS"), "2");
+ assertEquals(record.getMapField("segment_2").get("Server_2"), "CONSUMING");
+ assertTrue(record.getMapFields().containsKey("segment_1"));
+ assertEquals(record.getListField("segment_2"), List.of("Server_2",
"Server_1"));
+ }
+
+ /// cloneIdealState copies a ZNRecord field by field, so it must know every
field an IdealState stores in ZK. If a
+ /// Helix upgrade fails this test, make cloneIdealState copy the new state
(or leave it out if it is not stored, like
+ /// the ZK stat fields), then update the expected values.
+ @Test
+ public void testCloneIdealStateKnowsAllStoredFields()
+ throws IOException {
+ // Instance fields, including inherited ones, sorted
+ assertEquals(getInstanceFieldNames(ZNRecord.class),
+ List.of("_creationTime", "_deltaList", "_ephemeralOwner",
"_modifiedTime", "_serializer", "_version", "id",
+ "listFields", "mapFields", "rawPayload", "simpleFields"));
+ assertEquals(getInstanceFieldNames(IdealState.class), List.of("_record",
"_stat"));
+
+ // Of these, only the following are stored in ZK: the properties the
serializer's mapper can write, whatever their
+ // values, and the properties it writes for a record with all of them set
+ Set<String> storedFields = Set.of("id", "simpleFields", "mapFields",
"listFields", "rawPayload");
+ ObjectMapper mapper = ZNRecordSerializerMapper.get();
+
assertEquals(mapper.getSerializationConfig().introspect(mapper.constructType(ZNRecord.class)).findProperties()
+ .stream()
+ .filter(BeanPropertyDefinition::couldSerialize)
+ .map(BeanPropertyDefinition::getName)
+ .collect(Collectors.toSet()), storedFields);
+ ZNRecord record = new ZNRecord("myTable_REALTIME");
+ record.setSimpleField("REPLICAS", "2");
+ record.setMapField("segment_1", Map.of("Server_1", "ONLINE"));
+ record.setListField("segment_1", List.of("Server_1"));
+ record.setRawPayload(new byte[]{1});
+ byte[] bytes = new ZNRecordSerializer().serialize(record);
+ if (GZipCompressionUtil.isCompressed(bytes)) {
+ bytes = GZipCompressionUtil.uncompress(new ByteArrayInputStream(bytes));
+ }
+ Set<String> writtenFields = new HashSet<>();
+
JsonUtils.bytesToJsonNode(bytes).fieldNames().forEachRemaining(writtenFields::add);
+ assertEquals(writtenFields, storedFields);
+ }
+
+ /// Keeps duplicates, so that a field hiding an inherited one is caught.
+ private static List<String> getInstanceFieldNames(Class<?> clazz) {
+ List<String> fieldNames = new ArrayList<>();
+ for (Class<?> c = clazz; c != Object.class; c = c.getSuperclass()) {
+ for (Field field : c.getDeclaredFields()) {
+ if (!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic()) {
+ fieldNames.add(field.getName());
+ }
+ }
+ }
+ fieldNames.sort(null);
+ return fieldNames;
+ }
+
+ /// Exposes the ObjectMapper that ZNRecordSerializer writes ZNRecords with.
+ private static class ZNRecordSerializerMapper extends ZNRecordSerializer {
+ static ObjectMapper get() {
+ return mapper;
+ }
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]