This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 26b1ebc4e938 fix(record-index-bootstrap): sort record index keys by
UTF-8 bytes to match HFile sorting (#18941)
26b1ebc4e938 is described below
commit 26b1ebc4e9384d15c5599a4d97d522f781063b35
Author: Nada <[email protected]>
AuthorDate: Fri Jul 24 10:10:19 2026 -0400
fix(record-index-bootstrap): sort record index keys by UTF-8 bytes to match
HFile sorting (#18941)
* fix(HUDI-8898): sort record index keys by UTF-8 bytes to match HFile
ordering
* test(HUDI-8898): add record index bootstrap test for binary record keys
* Address review: fix UTF-8 key ordering in RI multi-slice lookup and MDT
compaction paths
* test(HUDI-8898): add TestStringUtils coverage for UTF-8 comparator
* Fix HoodieSortedMergeHandle to sort keys by UTF-8 bytes, not UTF-16
* Fix checkstyle: avoid escaped unicode chars in test keys
* Address review: note per-call UTF-8 encoding cost in compareUtf8Bytes
Javadoc
* Address review: restore record lambda param name in
JavaHoodieMetadataBulkInsertPartitioner
* Address review: sort Flink MDT bulk-insert keys by UTF-8 bytes, re-sort
filterRowKeys candidates, document unpaired-surrogate caveat
* Address review: merge duplicate binary-key bootstrap tests into a
parameterized test and correct the failure-mode comments to the read-side seek
miss
* Address review: add focused unit tests for Java and Spark MDT
partitioners, sorted buffer merge order, and comparator serialization
* Address review: add secondary index test with non-ASCII secondary key
values
* Address review: sort native-log MDT delta-commit keys by UTF-8 bytes
across engines, cover log-file lookups and assert write statuses in binary-key
tests
---------
Co-authored-by: voon <[email protected]>
---
.../java/org/apache/hudi/io/BaseCreateHandle.java | 7 +-
.../hudi/io/HoodieInlineLogAppendHandle.java | 5 +-
.../apache/hudi/io/HoodieSortedMergeHandle.java | 5 +-
.../apache/hudi/client/HoodieFlinkWriteClient.java | 5 +-
.../JavaHoodieMetadataBulkInsertPartitioner.java | 3 +-
...estJavaHoodieMetadataBulkInsertPartitioner.java | 100 ++++++++++++
.../SparkHoodieMetadataBulkInsertPartitioner.java | 2 +-
.../commit/BaseSparkCommitActionExecutor.java | 5 +-
...stSparkHoodieMetadataBulkInsertPartitioner.java | 61 +++++++
.../SortedKeyBasedFileGroupRecordBuffer.java | 12 +-
.../apache/hudi/common/util/HoodieRecordUtils.java | 6 +-
.../io/storage/HoodieNativeAvroHFileReader.java | 8 +-
.../hudi/metadata/HoodieBackedTableMetadata.java | 15 +-
.../TestSortedKeyBasedFileGroupRecordBuffer.java | 35 ++++
.../hudi/common/util/TestHoodieRecordUtils.java | 9 +-
.../org/apache/hudi/common/util/StringUtils.java | 36 +++++
.../apache/hudi/common/util/TestStringUtils.java | 64 ++++++++
.../hudi/functional/TestHoodieBackedMetadata.java | 176 +++++++++++++++++++++
.../java/org/apache/hudi/io/TestMergeHandle.java | 59 +++++++
.../functional/TestSecondaryIndexPruning.scala | 72 +++++++++
20 files changed, 664 insertions(+), 21 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
index b4732a55b38a..94bfa6cd2e4d 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
@@ -30,6 +30,7 @@ import org.apache.hudi.common.model.IOType;
import org.apache.hudi.common.model.MetadataValues;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.core.io.storage.HoodieFileWriter;
import org.apache.hudi.exception.HoodieException;
@@ -131,8 +132,10 @@ public abstract class BaseCreateHandle<T, I, K, O> extends
HoodieWriteHandle<T,
public void write() {
Iterator<String> keyIterator;
if (hoodieTable.requireSortedRecords()) {
- // Sorting the keys limits the amount of extra memory required for
writing sorted records
- keyIterator = recordMap.keySet().stream().sorted().iterator();
+ // Sorting the keys limits the amount of extra memory required for
writing sorted records.
+ // requireSortedRecords() is true only for HFile base files, which order
keys by UTF-8 bytes,
+ // not String (UTF-16) order, so sort with the matching comparator.
+ keyIterator =
recordMap.keySet().stream().sorted(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR).iterator();
} else {
keyIterator = recordMap.keySet().stream().iterator();
}
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieInlineLogAppendHandle.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieInlineLogAppendHandle.java
index 4db5bf2dc697..59709e74b1b0 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieInlineLogAppendHandle.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieInlineLogAppendHandle.java
@@ -40,6 +40,7 @@ import org.apache.hudi.common.util.DefaultSizeEstimator;
import org.apache.hudi.common.util.Lazy;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.SizeEstimator;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.exception.HoodieAppendException;
@@ -331,7 +332,9 @@ public class HoodieInlineLogAppendHandle<T, I, K, O>
extends HoodieAppendHandle<
case HFILE_DATA_BLOCK:
// Not supporting positions in HFile data blocks
header.remove(HeaderMetadataType.BASE_FILE_INSTANT_TIME_OF_RECORD_POSITIONS);
- records.sort(Comparator.comparing(HoodieRecord::getRecordKey));
+ // HFile orders keys by their raw UTF-8 bytes, so sort by UTF-8 bytes
rather than
+ // String (UTF-16) order to keep non-ASCII / binary keys consistent
with the writer.
+ records.sort(Comparator.comparing(HoodieRecord::getRecordKey,
StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR));
Map<String, String> hfileParams = new HashMap<>();
hfileParams.put(HFILE_COMPRESSION_ALGORITHM_NAME.key(),
writeConfig.getHFileCompressionAlgorithm());
hfileParams.put(HFILE_WITH_BLOOM_FILTER_ENABLED.key(),
Boolean.toString(writeConfig.hfileBloomFilterEnabled()));
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java
index 9456d5ce586b..7cc74c40afee 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java
@@ -24,6 +24,7 @@ import org.apache.hudi.common.model.HoodieBaseFile;
import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.exception.HoodieUpsertException;
import org.apache.hudi.keygen.BaseKeyGenerator;
@@ -47,7 +48,7 @@ import java.util.Queue;
@NotThreadSafe
public class HoodieSortedMergeHandle<T, I, K, O> extends
HoodieWriteMergeHandle<T, I, K, O> {
- private final Queue<String> newRecordKeysSorted = new PriorityQueue<>();
+ private final Queue<String> newRecordKeysSorted = new
PriorityQueue<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
public HoodieSortedMergeHandle(HoodieWriteConfig config, String instantTime,
HoodieTable<T, I, K, O> hoodieTable,
Iterator<HoodieRecord<T>> recordItr, String
partitionPath, String fileId, TaskContextSupplier taskContextSupplier,
@@ -78,7 +79,7 @@ public class HoodieSortedMergeHandle<T, I, K, O> extends
HoodieWriteMergeHandle<
// To maintain overall sorted order across updates and inserts, write any
new inserts whose keys are less than
// the oldRecord's key.
- while (!newRecordKeysSorted.isEmpty() &&
newRecordKeysSorted.peek().compareTo(key) <= 0) {
+ while (!newRecordKeysSorted.isEmpty() &&
StringUtils.compareUtf8Bytes(newRecordKeysSorted.peek(), key) <= 0) {
String keyToPreWrite = newRecordKeysSorted.remove();
if (keyToPreWrite.equals(key)) {
// will be handled as an update later
diff --git
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
index 90632c823a3f..cd677fa34fea 100644
---
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
+++
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java
@@ -35,6 +35,7 @@ import org.apache.hudi.common.model.TableServiceType;
import org.apache.hudi.common.model.WriteOperationType;
import org.apache.hudi.common.table.HoodieTableMetaClient;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.exception.HoodieNotSupportedException;
import org.apache.hudi.index.FlinkHoodieIndexFactory;
@@ -337,7 +338,9 @@ public class HoodieFlinkWriteClient<T>
Map<String, List<HoodieRecord<T>>> preppedRecordsByFileId =
preppedRecords.stream().parallel()
.collect(Collectors.groupingBy(r ->
r.getCurrentLocation().getFileId()));
return preppedRecordsByFileId.values().stream().parallel().map(records -> {
- records.sort(Comparator.comparing(HoodieRecord::getRecordKey));
+ // Only used for the metadata table, whose base files are HFiles ordered
by raw UTF-8 bytes,
+ // so sort by UTF-8 bytes rather than String (UTF-16) order for
non-ASCII / binary keys.
+ records.sort(Comparator.comparing(HoodieRecord::getRecordKey,
StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR));
HoodieWriteMetadata<List<WriteStatus>> result;
BucketInfo bucketInfo = new BucketInfo(BucketType.INSERT,
records.get(0).getCurrentLocation().getFileId(),
records.get(0).getPartitionPath());
try (AutoCloseableWriteHandle closeableHandle = new
AutoCloseableWriteHandle(bucketInfo, records.iterator(), instantTime, table,
true)) {
diff --git
a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java
b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java
index 0d81cc91fcff..e2a2137a1252 100644
---
a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java
+++
b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/metadata/JavaHoodieMetadataBulkInsertPartitioner.java
@@ -19,6 +19,7 @@
package org.apache.hudi.metadata;
import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.table.BulkInsertPartitioner;
import java.util.Comparator;
@@ -39,7 +40,7 @@ public class JavaHoodieMetadataBulkInsertPartitioner<T>
if (records.isEmpty()) {
return records;
}
- records.sort(Comparator.comparing(record ->
record.getKey().getRecordKey()));
+ records.sort(Comparator.comparing(record ->
record.getKey().getRecordKey(), StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR));
fileId =
HoodieTableMetadataUtil.getFileGroupPrefix(records.get(0).getCurrentLocation().getFileId());
return records;
}
diff --git
a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/metadata/TestJavaHoodieMetadataBulkInsertPartitioner.java
b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/metadata/TestJavaHoodieMetadataBulkInsertPartitioner.java
new file mode 100644
index 000000000000..2f600bac0954
--- /dev/null
+++
b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/metadata/TestJavaHoodieMetadataBulkInsertPartitioner.java
@@ -0,0 +1,100 @@
+/*
+ * 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.hudi.metadata;
+
+import org.apache.hudi.common.model.EmptyHoodieRecordPayload;
+import org.apache.hudi.common.model.HoodieAvroRecord;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieRecordLocation;
+import org.apache.hudi.common.util.StringUtils;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link JavaHoodieMetadataBulkInsertPartitioner}, which sorts
MDT/HFile record keys by raw
+ * UTF-8 bytes rather than String (UTF-16) order.
+ */
+class TestJavaHoodieMetadataBulkInsertPartitioner {
+
+ @Test
+ void repartitionRecordsSortsBinaryKeysByUtf8Bytes() {
+ // U+E000 (UTF-8 lead byte 0xEE) sorts BEFORE U+20000 (UTF-8 lead byte
0xF0) in raw UTF-8 byte
+ // order, but AFTER it under String.compareTo (UTF-16). This is the
pathological pair the
+ // partitioner's UTF-8 comparator must get right so HFile forward-only
seeks stay valid.
+ String bmpPrivateUse = new String(Character.toChars(0xE000));
+ String supplementary = new String(Character.toChars(0x20000));
+ // All records share one file group so the partitioner's single-group
assumption holds.
+ String fileId = "files-0000";
+
+ // Shuffled input mixing both prefixes plus ascii suffixes.
+ List<String> inputKeys = Arrays.asList(
+ supplementary + "-b",
+ "ascii-key",
+ bmpPrivateUse + "-a",
+ supplementary + "-a",
+ bmpPrivateUse + "-b");
+
+ List<HoodieRecord<EmptyHoodieRecordPayload>> records = new ArrayList<>();
+ for (String key : inputKeys) {
+ HoodieRecord<EmptyHoodieRecordPayload> record =
+ new HoodieAvroRecord<>(new HoodieKey(key, ""), new
EmptyHoodieRecordPayload());
+ record.unseal();
+ record.setCurrentLocation(new HoodieRecordLocation("001", fileId));
+ record.seal();
+ records.add(record);
+ }
+
+ JavaHoodieMetadataBulkInsertPartitioner<EmptyHoodieRecordPayload>
partitioner =
+ new JavaHoodieMetadataBulkInsertPartitioner<>();
+ List<HoodieRecord<EmptyHoodieRecordPayload>> sorted =
partitioner.repartitionRecords(records, 1);
+
+ assertTrue(partitioner.arePartitionRecordsSorted(), "Records must be
sorted");
+
+ List<String> actualKeys = new ArrayList<>();
+ for (HoodieRecord<EmptyHoodieRecordPayload> record : sorted) {
+ actualKeys.add(record.getRecordKey());
+ }
+ List<String> expectedKeys = new ArrayList<>(inputKeys);
+ expectedKeys.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
+ assertEquals(expectedKeys, actualKeys, "Records must be sorted by UTF-8
byte order");
+
+ // The divergent pair: every U+E000-prefixed key precedes every
U+20000-prefixed key in UTF-8
+ // byte order, the opposite of String.compareTo (UTF-16) order.
+ int lastBmpIndex = -1;
+ int firstSupplementaryIndex = actualKeys.size();
+ for (int i = 0; i < actualKeys.size(); i++) {
+ if (actualKeys.get(i).startsWith(bmpPrivateUse)) {
+ lastBmpIndex = i;
+ } else if (actualKeys.get(i).startsWith(supplementary) &&
firstSupplementaryIndex == actualKeys.size()) {
+ firstSupplementaryIndex = i;
+ }
+ }
+ assertTrue(lastBmpIndex < firstSupplementaryIndex,
+ "All U+E000-prefixed keys should sort before U+20000-prefixed keys in
UTF-8 order");
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java
index 3fd5f346ce11..38ff2f4a77c1 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/metadata/SparkHoodieMetadataBulkInsertPartitioner.java
@@ -68,7 +68,7 @@ public class SparkHoodieMetadataBulkInsertPartitioner
implements BulkInsertParti
@Override
public JavaRDD<HoodieRecord> repartitionRecords(JavaRDD<HoodieRecord>
records, int outputSparkPartitions) {
Comparator<Tuple2<Integer, String>> keyComparator =
- (Comparator<Tuple2<Integer, String>> & Serializable)(t1, t2) ->
t1._2.compareTo(t2._2);
+ (Comparator<Tuple2<Integer, String>> & Serializable)(t1, t2) ->
StringUtils.compareUtf8Bytes(t1._2, t2._2);
// Partition the records by their file group
JavaRDD<HoodieRecord> partitionedRDD = records
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
index d253210f4b56..06079a511bf8 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java
@@ -40,6 +40,7 @@ import org.apache.hudi.common.util.CommitUtils;
import org.apache.hudi.common.util.HoodieTimer;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.ReflectionUtils;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.data.HoodieJavaPairRDD;
@@ -326,10 +327,12 @@ public abstract class BaseSparkCommitActionExecutor<T>
extends
if (table.requireSortedRecords()) {
// Partition and sort within each partition as a single step. This is
faster than partitioning first and then
// applying a sort.
+ // requireSortedRecords() is true only for HFile base files, which order
keys by UTF-8 bytes,
+ // not String (UTF-16) order, so sort with the matching comparator.
Comparator<Tuple2<HoodieKey, Option<HoodieRecordLocation>>> comparator =
(Comparator<Tuple2<HoodieKey, Option<HoodieRecordLocation>>> & Serializable)
(t1, t2) -> {
HoodieKey key1 = t1._1;
HoodieKey key2 = t2._1;
- return key1.getRecordKey().compareTo(key2.getRecordKey());
+ return StringUtils.compareUtf8Bytes(key1.getRecordKey(),
key2.getRecordKey());
};
partitionedRDD =
mappedRDD.repartitionAndSortWithinPartitions(partitioner, comparator);
diff --git
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java
index aa46a177ac4e..0a8b7918d850 100644
---
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java
+++
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/TestSparkHoodieMetadataBulkInsertPartitioner.java
@@ -19,8 +19,10 @@
package org.apache.hudi.client;
+import org.apache.hudi.common.model.HoodieKey;
import org.apache.hudi.common.model.HoodieRecord;
import org.apache.hudi.common.model.HoodieRecordLocation;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.metadata.DefaultMetadataTableFileGroupIndexParser;
import org.apache.hudi.metadata.HoodieMetadataPayload;
import org.apache.hudi.metadata.MetadataPartitionType;
@@ -31,6 +33,7 @@ import org.apache.spark.api.java.JavaRDD;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -108,4 +111,62 @@ class TestSparkHoodieMetadataBulkInsertPartitioner extends
SparkClientFunctional
Set<String> fileIDPrefixes = IntStream.of(0, 1, 2,
4).mapToObj(partitioner::getFileIdPfx).collect(Collectors.toSet());
assertEquals(fileIDPrefixes, recordsPerFileGroup.keySet(), "fileIDPrefixes
should match the name of the MDT fileGroups");
}
+
+ @Test
+ public void testPartitionerSortsBinaryKeysByUtf8Bytes() {
+ // U+E000 (UTF-8 lead byte 0xEE) sorts BEFORE U+20000 (UTF-8 lead byte
0xF0) in raw UTF-8 byte
+ // order, but AFTER it under String.compareTo (UTF-16). All records target
a single MDT file group
+ // so the partitioner's only job here is the within-partition UTF-8 sort.
+ String fileGroupId = MetadataPartitionType.FILES.getFileIdPrefix() + "000";
+ String bmpPrivateUse = new String(Character.toChars(0xE000));
+ String supplementary = new String(Character.toChars(0x20000));
+
+ // Shuffled input mixing both prefixes plus an ascii key.
+ List<String> inputKeys = Arrays.asList(
+ supplementary + "-b",
+ "ascii-key",
+ bmpPrivateUse + "-a",
+ supplementary + "-a",
+ bmpPrivateUse + "-b");
+
+ List<HoodieRecord> records = new ArrayList<>();
+ for (String key : inputKeys) {
+ // createPartitionListRecord fixes the record key, so start from it (for
a valid MDT payload)
+ // and rebind an explicitly chosen HoodieKey via newInstance.
+ HoodieRecord r =
HoodieMetadataPayload.createPartitionListRecord(Collections.EMPTY_LIST)
+ .newInstance(new HoodieKey(key, ""));
+ r.unseal();
+ r.setCurrentLocation(new HoodieRecordLocation("001", fileGroupId));
+ r.seal();
+ records.add(r);
+ }
+
+ SparkHoodieMetadataBulkInsertPartitioner partitioner =
+ new SparkHoodieMetadataBulkInsertPartitioner(new
DefaultMetadataTableFileGroupIndexParser(1));
+ JavaRDD<HoodieRecord> partitionedRecords =
+ partitioner.repartitionRecords(jsc().parallelize(records,
records.size()), 0);
+
+ // All records map to one file group, hence a single partition.
+ assertEquals(1, partitionedRecords.getNumPartitions(), "All records map to
a single file group");
+ assertTrue(partitioner.arePartitionRecordsSorted(), "Must be sorted");
+
+ List<String> actualKeys = partitionedRecords.map(r ->
r.getRecordKey()).collect();
+ List<String> expectedKeys = new ArrayList<>(inputKeys);
+ expectedKeys.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
+ assertEquals(expectedKeys, actualKeys, "Records must be sorted by UTF-8
byte order within the file group");
+
+ // The divergent pair: every U+E000-prefixed key precedes every
U+20000-prefixed key in UTF-8
+ // byte order, the opposite of String.compareTo (UTF-16) order.
+ int lastBmpIndex = -1;
+ int firstSupplementaryIndex = actualKeys.size();
+ for (int i = 0; i < actualKeys.size(); i++) {
+ if (actualKeys.get(i).startsWith(bmpPrivateUse)) {
+ lastBmpIndex = i;
+ } else if (actualKeys.get(i).startsWith(supplementary) &&
firstSupplementaryIndex == actualKeys.size()) {
+ firstSupplementaryIndex = i;
+ }
+ }
+ assertTrue(lastBmpIndex < firstSupplementaryIndex,
+ "All U+E000-prefixed keys should sort before U+20000-prefixed keys in
UTF-8 order");
+ }
}
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java
index 8fa9aa1dad91..083a7762bec2 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/table/read/buffer/SortedKeyBasedFileGroupRecordBuffer.java
@@ -27,6 +27,7 @@ import org.apache.hudi.common.table.PartialUpdateMode;
import org.apache.hudi.common.table.read.BufferedRecord;
import org.apache.hudi.common.table.read.UpdateProcessor;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.ValidationUtils;
import java.io.IOException;
@@ -58,14 +59,18 @@ class SortedKeyBasedFileGroupRecordBuffer<T> extends
KeyBasedFileGroupRecordBuff
@Override
protected void initializeLogRecordIterator() {
- logRecordIterator =
records.values().stream().sorted(Comparator.comparing(BufferedRecord::getRecordKey)).iterator();
+ // This buffer is only used when the base file format is HFile
(requireSortedRecords()), which orders
+ // keys by UTF-8 bytes, not String (UTF-16) order, so sort with the
matching comparator.
+ logRecordIterator = records.values().stream()
+ .sorted(Comparator.comparing(BufferedRecord::getRecordKey,
StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR))
+ .iterator();
}
@Override
protected boolean hasNextBaseRecord(T baseRecord) throws IOException {
String recordKey =
readerContext.getRecordContext().getRecordKey(baseRecord, readerSchema);
int comparison = 0;
- while (!getLogRecordKeysSorted().isEmpty() && (comparison =
getLogRecordKeysSorted().peek().compareTo(recordKey)) <= 0) {
+ while (!getLogRecordKeysSorted().isEmpty() && (comparison =
StringUtils.compareUtf8Bytes(getLogRecordKeysSorted().peek(), recordKey)) <= 0)
{
String nextLogRecordKey = getLogRecordKeysSorted().poll();
if (comparison == 0) {
break; // Log record key matches the base record key, exit loop after
removing the key from the queue of log record keys
@@ -102,7 +107,8 @@ class SortedKeyBasedFileGroupRecordBuffer<T> extends
KeyBasedFileGroupRecordBuff
private Queue<String> getLogRecordKeysSorted() {
if (logRecordKeysSorted == null) {
- logRecordKeysSorted =
records.keySet().stream().map(Object::toString).collect(Collectors.toCollection(PriorityQueue::new));
+ logRecordKeysSorted = records.keySet().stream().map(Object::toString)
+ .collect(Collectors.toCollection(() -> new
PriorityQueue<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR)));
}
return logRecordKeysSorted;
}
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/util/HoodieRecordUtils.java
b/hudi-common/src/main/java/org/apache/hudi/common/util/HoodieRecordUtils.java
index 1718092eba68..27337de679cb 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/util/HoodieRecordUtils.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/util/HoodieRecordUtils.java
@@ -224,12 +224,14 @@ public class HoodieRecordUtils {
}
/**
- * Returns an iterator over the input records sorted by record key.
+ * Returns an iterator over the input records sorted by record key in UTF-8
byte order. Callers
+ * use this to feed HFile-backed writers ({@code requireSortedRecords()}),
and HFiles order keys
+ * by their raw UTF-8 bytes, not String (UTF-16) order.
*/
public static <T> Iterator<HoodieRecord<T>>
sortRecordsByRecordKey(Iterator<HoodieRecord<T>> records) {
List<HoodieRecord<T>> sortedRecords = new ArrayList<>();
records.forEachRemaining(sortedRecords::add);
- sortedRecords.sort(Comparator.comparing(HoodieRecord::getRecordKey));
+ sortedRecords.sort(Comparator.comparing(HoodieRecord::getRecordKey,
StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR));
return sortedRecords.iterator();
}
diff --git
a/hudi-common/src/main/java/org/apache/hudi/core/io/storage/HoodieNativeAvroHFileReader.java
b/hudi-common/src/main/java/org/apache/hudi/core/io/storage/HoodieNativeAvroHFileReader.java
index 23e83474eb6d..0cf5eee29b66 100644
---
a/hudi-common/src/main/java/org/apache/hudi/core/io/storage/HoodieNativeAvroHFileReader.java
+++
b/hudi-common/src/main/java/org/apache/hudi/core/io/storage/HoodieNativeAvroHFileReader.java
@@ -31,6 +31,7 @@ import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaField;
import org.apache.hudi.common.util.Lazy;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.ValidationUtils;
import org.apache.hudi.common.util.collection.ClosableIterator;
import org.apache.hudi.common.util.collection.CloseableMappingIterator;
@@ -136,8 +137,11 @@ public class HoodieNativeAvroHFileReader extends
HoodieAvroHFileReaderImplBase {
public Set<Pair<String, Long>> filterRowKeys(Set<String> candidateRowKeys) {
try (HFileReader reader = readerFactory.createHFileReader()) {
reader.seekTo();
- // candidateRowKeys must be sorted
- return (candidateRowKeys instanceof TreeSet ? candidateRowKeys : new
TreeSet<>(candidateRowKeys))
+ // candidateRowKeys must be sorted by UTF-8 bytes to match HFile
ordering because the reader
+ // only seeks forward.
+ TreeSet<String> sortedRowKeys = new
TreeSet<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
+ sortedRowKeys.addAll(candidateRowKeys);
+ return sortedRowKeys
.stream()
.filter(k -> {
try {
diff --git
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java
index 188ff275dad3..46cf00a5326a 100644
---
a/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java
+++
b/hudi-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadata.java
@@ -56,6 +56,7 @@ import org.apache.hudi.common.table.timeline.HoodieInstant;
import org.apache.hudi.common.table.view.HoodieTableFileSystemView;
import org.apache.hudi.common.util.ConfigUtils;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.ValidationUtils;
import org.apache.hudi.common.util.collection.ClosableIterator;
import org.apache.hudi.common.util.collection.ClosableSortedDedupingIterator;
@@ -229,9 +230,9 @@ public class HoodieBackedTableMetadata extends
BaseTableMetadata {
boolean shouldLoadInMemory) {
// Apply key encoding
List<String> sortedKeyPrefixes = new ArrayList<>(rawKeys.map(key ->
key.encode()).collectAsList());
- // Sort the prefixes so that keys are looked up in order
- // Sort must come after encoding.
- Collections.sort(sortedKeyPrefixes);
+ // Sort the prefixes so that keys are looked up in order. Sort must come
after encoding.
+ // Sort by UTF-8 bytes to match the HFile order; the reader seeks forward
without rewinding.
+ sortedKeyPrefixes.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
// NOTE: Since we partition records to a particular file-group by full
key, we will have
// to scan all file-groups for all key-prefixes as each of these
might contain some
@@ -256,7 +257,10 @@ public class HoodieBackedTableMetadata extends
BaseTableMetadata {
private static TreeSet<String>
getDistinctSortedKeysForSingleSlice(HoodieData<String> keys) {
List<String> keysList = keys.collectAsList();
- return new TreeSet<>(keysList);
+ // Order by UTF-8 bytes to match the HFile order used for point lookups.
+ TreeSet<String> sortedKeys = new
TreeSet<>(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
+ sortedKeys.addAll(keysList);
+ return sortedKeys;
}
/**
@@ -314,6 +318,9 @@ public class HoodieBackedTableMetadata extends
BaseTableMetadata {
}
distinctSortedKeyIter.forEachRemaining(keysList::add);
}
+ // The shuffle above repartitions/sorts by String (UTF-16) order,
but the HFile reader below
+ // does a forward-only seek in UTF-8 byte order. Re-sort so the two
agree.
+ keysList.sort(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
FileSlice fileSlice =
fileSlices.get(mappingFunction.apply(keysList.get(0), numFileSlices));
return lookupRecordsItr(partitionName, keysList, fileSlice,
!isSecondaryIndex);
};
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java
index 329b3e382ba8..4dde1b53c37c 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/table/read/buffer/TestSortedKeyBasedFileGroupRecordBuffer.java
@@ -56,6 +56,7 @@ import java.util.stream.Stream;
import static
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY;
import static
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
@@ -188,6 +189,40 @@ class TestSortedKeyBasedFileGroupRecordBuffer extends
BaseTestFileGroupRecordBuf
assertEquals(1, readStats.getNumDeletes());
}
+ @Test
+ void readBaseFileAndLogFileWithBinaryKeys() throws IOException {
+ // U+E000 (UTF-8 lead byte 0xEE) sorts BEFORE U+20000 (UTF-8 lead byte
0xF0) in raw UTF-8 byte
+ // order, but AFTER it under String.compareTo (UTF-16). The base-file
record carries the
+ // U+E000-prefixed (UTF-8-smaller, UTF-16-larger) key and the log carries
the U+20000-prefixed
+ // (UTF-8-larger, UTF-16-smaller) key, so a correct merge must emit them
in UTF-8 byte order.
+ String bmpPrivateUse = new String(Character.toChars(0xE000));
+ String supplementary = new String(Character.toChars(0x20000));
+ TestRecord asciiA = new TestRecord("a", 0);
+ TestRecord asciiB = new TestRecord("b", 0);
+ TestRecord bmpRecord = new TestRecord(bmpPrivateUse + "-base", 0);
+ TestRecord supplementaryRecord = new TestRecord(supplementary + "-log", 0);
+
+ HoodieReadStats readStats = new HoodieReadStats();
+ HoodieReaderContext<TestRecord> mockReaderContext =
mock(HoodieReaderContext.class, RETURNS_DEEP_STUBS);
+ SortedKeyBasedFileGroupRecordBuffer<TestRecord> fileGroupRecordBuffer =
buildSortedKeyBasedFileGroupRecordBuffer(mockReaderContext, readStats);
+
+ // Base-file records must already be in UTF-8 byte order: "a" (0x61) then
the U+E000 key (0xEE...).
+
fileGroupRecordBuffer.setBaseFileIterator(ClosableIterator.wrap(Arrays.asList(asciiA,
bmpRecord).iterator()));
+
+ // Log records are supplied shuffled; the buffer sorts them by UTF-8 bytes
before merging.
+ HoodieDataBlock dataBlock = mock(HoodieDataBlock.class);
+
when(dataBlock.getSchema()).thenReturn(HoodieTestDataGenerator.HOODIE_SCHEMA);
+ when(dataBlock.getEngineRecordIterator(mockReaderContext)).thenReturn(
+ ClosableIterator.wrap(Arrays.asList(supplementaryRecord,
asciiB).iterator()));
+ fileGroupRecordBuffer.processDataBlock(dataBlock, Option.empty());
+
+ List<TestRecord> actualRecords =
getActualRecordsForSortedKeyBased(fileGroupRecordBuffer);
+ // Expected UTF-8 byte order: "a", "b", U+E000 key, U+20000 key; nothing
is dropped.
+ assertEquals(Arrays.asList(asciiA, asciiB, bmpRecord,
supplementaryRecord), actualRecords);
+ // The U+E000-prefixed base record precedes the U+20000-prefixed log
record (reverse of UTF-16).
+ assertTrue(actualRecords.indexOf(bmpRecord) <
actualRecords.indexOf(supplementaryRecord));
+ }
+
private SortedKeyBasedFileGroupRecordBuffer<TestRecord>
buildSortedKeyBasedFileGroupRecordBuffer(HoodieReaderContext<TestRecord>
mockReaderContext, HoodieReadStats readStats) {
when(mockReaderContext.getSchemaHandler().getRequiredSchema()).thenReturn(HoodieTestDataGenerator.HOODIE_SCHEMA);
when(mockReaderContext.getSchemaHandler().getInternalSchema()).thenReturn(InternalSchema.getEmptyInternalSchema());
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestHoodieRecordUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestHoodieRecordUtils.java
index 2219f8043760..c76c194be133 100644
---
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestHoodieRecordUtils.java
+++
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestHoodieRecordUtils.java
@@ -71,9 +71,16 @@ class TestHoodieRecordUtils {
@Test
void sortRecordsByRecordKey() {
+ // U+E000 (UTF-8 lead byte 0xEE) sorts BEFORE U+20000 (UTF-8 lead byte
0xF0) in raw UTF-8 byte
+ // order, but AFTER it under String.compareTo (UTF-16). The sort feeds
HFile-backed writers, so
+ // it must produce UTF-8 byte order.
+ String bmpPrivateUseKey = new String(Character.toChars(0xE000)) + "key";
+ String supplementaryKey = new String(Character.toChars(0x20000)) + "key";
List<HoodieRecord<DefaultHoodieRecordPayload>> records = Arrays.asList(
record("key3"),
+ record(supplementaryKey),
record("key1"),
+ record(bmpPrivateUseKey),
record("key2"));
Iterator<HoodieRecord<DefaultHoodieRecordPayload>> sortedRecords =
@@ -81,7 +88,7 @@ class TestHoodieRecordUtils {
List<String> sortedKeys = new ArrayList<>();
sortedRecords.forEachRemaining(record ->
sortedKeys.add(record.getRecordKey()));
- assertEquals(Arrays.asList("key1", "key2", "key3"), sortedKeys);
+ assertEquals(Arrays.asList("key1", "key2", "key3", bmpPrivateUseKey,
supplementaryKey), sortedKeys);
}
@Test
diff --git a/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java
b/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java
index 4f3a651d7914..bd8af56094dc 100644
--- a/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java
+++ b/hudi-io/src/main/java/org/apache/hudi/common/util/StringUtils.java
@@ -21,10 +21,12 @@ package org.apache.hudi.common.util;
import javax.annotation.Nullable;
+import java.io.Serializable;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -120,6 +122,40 @@ public class StringUtils {
return str.getBytes(StandardCharsets.UTF_8);
}
+ /**
+ * Serializable comparator ordering strings by their unsigned UTF-8 byte
representation. See
+ * {@link #compareUtf8Bytes(String, String)} for the rationale and
null-handling contract.
+ */
+ public static final Comparator<String> UTF8_LEXICOGRAPHIC_COMPARATOR =
+ (Comparator<String> & Serializable) StringUtils::compareUtf8Bytes;
+
+ /**
+ * Compares two strings by their unsigned UTF-8 byte order, matching the
ordering HFiles enforce
+ * (HBase's {@code CellComparatorImpl}). Unlike {@link
String#compareTo(String)} (UTF-16 code unit
+ * order), this stays consistent with HFile ordering for non-ASCII / binary
keys.
+ *
+ * <p>Neither argument may be {@code null}; like {@link
String#compareTo(String)}, a {@code null}
+ * argument throws {@link NullPointerException}.
+ *
+ * <p>Assumes well-formed UTF-16 input: {@code String#getBytes(UTF_8)}
replaces unpaired surrogates
+ * with {@code '?'}, so strings differing only in unpaired surrogates
compare equal.
+ *
+ * <p>Note: encodes both strings to UTF-8 on every call; for very large
sorts consider
+ * pre-encoding keys to byte arrays once and comparing those.
+ */
+ public static int compareUtf8Bytes(String s1, String s2) {
+ byte[] b1 = getUTF8Bytes(s1);
+ byte[] b2 = getUTF8Bytes(s2);
+ int len = Math.min(b1.length, b2.length);
+ for (int i = 0; i < len; i++) {
+ int cmp = (b1[i] & 0xFF) - (b2[i] & 0xFF);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+ return b1.length - b2.length;
+ }
+
public static String fromUTF8Bytes(byte[] bytes) {
return fromUTF8Bytes(bytes, 0, bytes.length);
}
diff --git
a/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java
b/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java
index 3be3bfe3f998..265ebbf305ef 100644
--- a/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java
+++ b/hudi-io/src/test/java/org/apache/hudi/common/util/TestStringUtils.java
@@ -21,12 +21,17 @@ package org.apache.hudi.common.util;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -283,4 +288,63 @@ public class TestStringUtils {
assertEquals("abc", StringUtils.stripEnd("abc", ""));
assertEquals("abc", StringUtils.stripEnd("abcabab", "ab"));
}
+
+ @Test
+ public void testCompareUtf8BytesAsciiMatchesStringCompareTo() {
+ // Pure ASCII bytes equal their UTF-16 code unit values, so UTF-8 byte
order and
+ // String.compareTo order coincide.
+ assertEquals(Integer.signum("apple".compareTo("banana")),
Integer.signum(StringUtils.compareUtf8Bytes("apple", "banana")));
+ assertEquals(Integer.signum("banana".compareTo("apple")),
Integer.signum(StringUtils.compareUtf8Bytes("banana", "apple")));
+ assertEquals(0, StringUtils.compareUtf8Bytes("apple", "apple"));
+ }
+
+ @Test
+ public void
testCompareUtf8BytesSupplementaryPairFlipsOrderVsStringCompareTo() {
+ // U+E000 (BMP private-use, UTF-8 lead byte 0xEE) vs U+20000
(supplementary plane, UTF-8 lead byte
+ // 0xF0). In UTF-16, U+20000 is encoded as a surrogate pair starting with
0xD840, which is < 0xE000,
+ // so String.compareTo orders U+20000 first. In UTF-8 byte order 0xF0 >
0xEE, flipping the order --
+ // exactly the pathological shape that breaks HFile's forward-only seek
under String.compareTo.
+ String bmpPrivateUse = new String(Character.toChars(0xE000));
+ String supplementary = new String(Character.toChars(0x20000));
+
+ assertTrue(bmpPrivateUse.compareTo(supplementary) > 0,
+ "String.compareTo should order U+E000 after U+20000 (UTF-16 code unit
order)");
+ assertTrue(StringUtils.compareUtf8Bytes(bmpPrivateUse, supplementary) < 0,
+ "compareUtf8Bytes should order U+E000 before U+20000 (UTF-8 byte
order)");
+ }
+
+ @Test
+ public void testCompareUtf8BytesEmptyPrefixAndIdenticalStrings() {
+ assertTrue(StringUtils.compareUtf8Bytes("", "a") < 0);
+ assertTrue(StringUtils.compareUtf8Bytes("a", "") > 0);
+ assertEquals(0, StringUtils.compareUtf8Bytes("", ""));
+ assertTrue(StringUtils.compareUtf8Bytes("ab", "abc") < 0);
+ assertTrue(StringUtils.compareUtf8Bytes("abc", "ab") > 0);
+ assertEquals(0, StringUtils.compareUtf8Bytes("abc", "abc"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testUtf8LexicographicComparatorSerializableAndRejectsNull()
throws Exception {
+ // Like String.compareTo, a null argument is rejected.
+ assertThrows(NullPointerException.class, () ->
StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR.compare(null, "a"));
+
+ // The comparator is declared as (Comparator<String> & Serializable) so
Spark can capture it inside
+ // serialized closures. Round-trip it through Java serialization and
confirm the deserialized
+ // instance still orders keys by UTF-8 bytes for the divergent U+E000 vs
U+20000 pair (U+E000's
+ // UTF-8 lead byte 0xEE sorts before U+20000's 0xF0, the reverse of
String.compareTo / UTF-16).
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(StringUtils.UTF8_LEXICOGRAPHIC_COMPARATOR);
+ }
+ Comparator<String> deserialized;
+ try (ObjectInputStream ois = new ObjectInputStream(new
ByteArrayInputStream(baos.toByteArray()))) {
+ deserialized = (Comparator<String>) ois.readObject();
+ }
+
+ String bmpPrivateUse = new String(Character.toChars(0xE000));
+ String supplementary = new String(Character.toChars(0x20000));
+ assertTrue(deserialized.compare(bmpPrivateUse, supplementary) < 0,
+ "Deserialized comparator should order U+E000 before U+20000 (UTF-8
byte order)");
+ }
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java
index f3b3bbf3c4c2..ec0d405fad91 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java
+++
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieBackedMetadata.java
@@ -39,6 +39,7 @@ import org.apache.hudi.common.engine.HoodieEngineContext;
import org.apache.hudi.common.fs.ConsistencyGuardConfig;
import org.apache.hudi.common.fs.FSUtils;
import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieAvroIndexedRecord;
import org.apache.hudi.common.model.HoodieBaseFile;
import org.apache.hudi.common.model.HoodieCleaningPolicy;
import org.apache.hudi.common.model.HoodieCommitMetadata;
@@ -162,6 +163,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
@@ -1944,6 +1946,180 @@ public class TestHoodieBackedMetadata extends
TestHoodieMetadataBase {
testTableOperationsForMetaIndexImpl(writeConfig);
}
+ /**
+ * Record index bootstrap over binary / non-ASCII record keys must succeed.
The RI HFile orders
+ * keys by their raw UTF-8 bytes, so the bulk-insert partitioner must sort
by UTF-8 bytes too;
+ * sorting by {@link String#compareTo(String)} (UTF-16) lays the HFile
entries out of order
+ * relative to their UTF-8 bytes.
+ *
+ * <p>The failure is read-side, not write-side: the native {@code
HFileWriterImpl.append} does no
+ * key-order validation, so a mis-sorted HFile is still written
successfully. The forward-only
+ * HFile reader ({@code HFileReaderImpl.seekTo}) then either throws {@code
IllegalStateException}
+ * on a backward seek or silently misses keys, which the read-back count
assertion at the end of
+ * this test catches.
+ *
+ * <p>{@code riFileGroupCount == 1} covers the single-slice lookup path;
{@code riFileGroupCount == 4}
+ * covers the multi-slice {@code mapGroupsByKey} lookup path in
+ * {@code HoodieBackedTableMetadata#lookupIndexRecords}, which repartitions
keys in String/UTF-16
+ * order before doing a forward-only HFile seek in UTF-8 order.
+ */
+ @ParameterizedTest
+ @ValueSource(ints = {1, 4})
+ public void testRecordIndexBootstrapWithBinaryRecordKeys(int
riFileGroupCount) throws Exception {
+ init(COPY_ON_WRITE, true);
+ HoodieSparkEngineContext engineContext = new HoodieSparkEngineContext(jsc);
+
+ // First commit with the record index disabled: write base files with
binary record keys.
+ List<HoodieRecord> records =
generateRecordsWithBinaryKeys(WriteClientTestUtils.createNewInstantTime(), 0,
200);
+ HoodieWriteConfig firstConfig = getWriteConfigBuilder(true, true,
false).build();
+ String firstCommitTime = WriteClientTestUtils.createNewInstantTime();
+ try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext,
firstConfig)) {
+ WriteClientTestUtils.startCommitWithTime(client, firstCommitTime);
+ List<WriteStatus> writeStatuses = client.insert(jsc.parallelize(records,
1), firstCommitTime).collect();
+ assertNoWriteErrors(writeStatuses);
+ client.commit(firstCommitTime, jsc.parallelize(writeStatuses));
+ }
+ metaClient = HoodieTableMetaClient.reload(metaClient);
+
assertFalse(metaClient.getTableConfig().isMetadataPartitionAvailable(RECORD_INDEX));
+
+ // Enable the record index. The next commit triggers the bootstrap,
reading the binary keys from
+ // the base files above. One file group puts all keys in a single HFile;
more than one file group
+ // exercises the multi-slice lookup path on read-back.
+ HoodieWriteConfig riConfig = getWriteConfigBuilder(false, true, false)
+ .withMetadataConfig(HoodieMetadataConfig.newBuilder()
+ .enable(true)
+ .withEnableGlobalRecordLevelIndex(true)
+ .withRecordIndexFileGroupCount(riFileGroupCount, riFileGroupCount)
+ .build())
+ .build();
+
+ String secondCommitTime = WriteClientTestUtils.createNewInstantTime();
+ // Disjoint key range so the bootstrapped keys are not mutated.
+ List<HoodieRecord> secondBatch =
generateRecordsWithBinaryKeys(secondCommitTime, 1000, 20);
+ try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext,
riConfig)) {
+ WriteClientTestUtils.startCommitWithTime(client, secondCommitTime);
+ // Without the fix the mis-sorted record-index HFile is still written;
the failure surfaces on
+ // read-back below, so the write itself is expected to succeed here.
+ List<WriteStatus> writeStatuses =
client.insert(jsc.parallelize(secondBatch, 1), secondCommitTime).collect();
+ assertNoWriteErrors(writeStatuses);
+ client.commit(secondCommitTime, jsc.parallelize(writeStatuses));
+ }
+
+ // The record index partition should exist and resolve every key: the
bootstrapped keys live in
+ // the record-index base HFiles, while the second-batch keys still sit in
un-compacted metadata
+ // log files at this point, so the lookup covers the log-side seek path
with binary keys too.
+ metaClient = HoodieTableMetaClient.reload(metaClient);
+
assertTrue(metaClient.getTableConfig().isMetadataPartitionAvailable(RECORD_INDEX));
+ HoodieTableMetadata metadataReader =
metaClient.getTableFormat().getMetadataFactory().create(
+ context, storage, riConfig.getMetadataConfig(),
riConfig.getBasePath());
+ List<String> allKeys = Stream.concat(records.stream(),
secondBatch.stream())
+ .map(HoodieRecord::getRecordKey).collect(Collectors.toList());
+ // With more than one file group, readRecordIndexLocationsWithKeys
triggers the mapGroupsByKey
+ // multi-slice path.
+ HoodiePairData<String, HoodieRecordGlobalLocation> recordIndexData =
metadataReader
+ .readRecordIndexLocationsWithKeys(HoodieListData.eager(allKeys));
+ try {
+ Map<String, HoodieRecordGlobalLocation> result =
HoodieDataUtils.dedupeAndCollectAsMap(recordIndexData);
+ assertEquals(allKeys.size(), result.size(),
+ "Record index should resolve every binary key, bootstrapped or still
in a metadata log file.");
+ } finally {
+ recordIndexData.unpersistWithDependencies();
+ }
+ }
+
+ /**
+ * Generates {@code count} records with binary record keys interleaving
U+E000 (BMP) and U+20000
+ * (supplementary) prefixes, whose UTF-16 char order is the reverse of their
UTF-8 byte order.
+ */
+ private List<HoodieRecord> generateRecordsWithBinaryKeys(String commitTime,
int startIndex, int count) {
+ List<HoodieRecord> baseRecords = dataGen.generateInserts(commitTime,
count);
+ String[] binaryPrefixes = {new String(Character.toChars(0xE000)), new
String(Character.toChars(0x20000))};
+ List<HoodieRecord> binaryRecords = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ HoodieRecord baseRecord = baseRecords.get(i);
+ int index = startIndex + i;
+ String binaryKey = binaryPrefixes[index % binaryPrefixes.length] +
String.format("%08d", index);
+ binaryRecords.add(new HoodieAvroIndexedRecord(
+ new HoodieKey(binaryKey, baseRecord.getPartitionPath()),
+ (IndexedRecord) baseRecord.getData()));
+ }
+ return binaryRecords;
+ }
+
+ /**
+ * Same as {@link #testRecordIndexBootstrapWithBinaryRecordKeys(int)} but
forces an MDT compaction after
+ * bootstrap, exercising the {@code BaseCreateHandle} / {@code
SortedKeyBasedFileGroupRecordBuffer}
+ * sort-order paths hit when the record-index base HFile is rewritten.
+ */
+ @Test
+ public void testRecordIndexBootstrapWithBinaryRecordKeysAfterCompaction()
throws Exception {
+ init(COPY_ON_WRITE, true);
+ HoodieSparkEngineContext engineContext = new HoodieSparkEngineContext(jsc);
+
+ List<HoodieRecord> records =
generateRecordsWithBinaryKeys(WriteClientTestUtils.createNewInstantTime(), 0,
200);
+ HoodieWriteConfig firstConfig = getWriteConfigBuilder(true, true,
false).build();
+ String firstCommitTime = WriteClientTestUtils.createNewInstantTime();
+ try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext,
firstConfig)) {
+ WriteClientTestUtils.startCommitWithTime(client, firstCommitTime);
+ List<WriteStatus> writeStatuses = client.insert(jsc.parallelize(records,
1), firstCommitTime).collect();
+ assertNoWriteErrors(writeStatuses);
+ client.commit(firstCommitTime, jsc.parallelize(writeStatuses));
+ }
+ metaClient = HoodieTableMetaClient.reload(metaClient);
+
assertFalse(metaClient.getTableConfig().isMetadataPartitionAvailable(RECORD_INDEX));
+
+ // A single delta commit is enough to trigger compaction on the very next
delta commit.
+ HoodieWriteConfig riConfig = getWriteConfigBuilder(false, true, false)
+ .withMetadataConfig(HoodieMetadataConfig.newBuilder()
+ .enable(true)
+ .withEnableGlobalRecordLevelIndex(true)
+ .withRecordIndexFileGroupCount(1, 1)
+ .withMaxNumDeltaCommitsBeforeCompaction(1)
+ .build())
+ .build();
+
+ List<HoodieRecord> allKeys = new ArrayList<>(records);
+ try (SparkRDDWriteClient client = new SparkRDDWriteClient(engineContext,
riConfig)) {
+ // Bootstrap: writes the initial record-index HFile from the binary keys
above.
+ String secondCommitTime = WriteClientTestUtils.createNewInstantTime();
+ List<HoodieRecord> secondBatch =
generateRecordsWithBinaryKeys(secondCommitTime, 1000, 20);
+ WriteClientTestUtils.startCommitWithTime(client, secondCommitTime);
+ // The mis-sorted bootstrap write succeeds; key ordering is validated by
the read-back below.
+ List<WriteStatus> secondWriteStatuses =
client.insert(jsc.parallelize(secondBatch, 1), secondCommitTime).collect();
+ assertNoWriteErrors(secondWriteStatuses);
+ client.commit(secondCommitTime, jsc.parallelize(secondWriteStatuses));
+ allKeys.addAll(secondBatch);
+
+ // The next delta commit on the record index partition triggers
compaction, rewriting the base
+ // HFile via BaseCreateHandle / SortedKeyBasedFileGroupRecordBuffer.
+ String thirdCommitTime = WriteClientTestUtils.createNewInstantTime();
+ List<HoodieRecord> thirdBatch =
generateRecordsWithBinaryKeys(thirdCommitTime, 2000, 20);
+ WriteClientTestUtils.startCommitWithTime(client, thirdCommitTime);
+ // The compaction rewrite of the base HFile also succeeds; ordering is
validated on read-back.
+ List<WriteStatus> thirdWriteStatuses =
client.insert(jsc.parallelize(thirdBatch, 1), thirdCommitTime).collect();
+ assertNoWriteErrors(thirdWriteStatuses);
+ client.commit(thirdCommitTime, jsc.parallelize(thirdWriteStatuses));
+ allKeys.addAll(thirdBatch);
+ }
+
+ metaClient = HoodieTableMetaClient.reload(metaClient);
+ HoodieTableMetadata metadataReader =
metaClient.getTableFormat().getMetadataFactory().create(
+ context, storage, riConfig.getMetadataConfig(),
riConfig.getBasePath());
+ assertTrue(metadataReader.getLatestCompactionTime().isPresent(),
+ "Record index partition should have been compacted by now.");
+
+ List<String> allRecordKeys =
allKeys.stream().map(HoodieRecord::getRecordKey).collect(Collectors.toList());
+ HoodiePairData<String, HoodieRecordGlobalLocation> recordIndexData =
metadataReader
+ .readRecordIndexLocationsWithKeys(HoodieListData.eager(allRecordKeys));
+ try {
+ Map<String, HoodieRecordGlobalLocation> result =
HoodieDataUtils.dedupeAndCollectAsMap(recordIndexData);
+ assertEquals(allRecordKeys.size(), result.size(),
+ "Record index should resolve every binary key after the record index
partition has been compacted.");
+ } finally {
+ recordIndexData.unpersistWithDependencies();
+ }
+ }
+
/**
* First attempt at bootstrap failed but the file slices get created. The
next bootstrap should continue successfully.
*/
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java
index 2e6db79cb3ef..2e1cee6b9f14 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java
+++
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/io/TestMergeHandle.java
@@ -57,6 +57,7 @@ import org.apache.hudi.common.util.DateTimeUtils;
import org.apache.hudi.common.util.HoodieRecordUtils;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.common.util.ParquetUtils;
+import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.collection.ClosableIterator;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.config.HoodieWriteConfig;
@@ -77,6 +78,7 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@@ -198,6 +200,57 @@ public class TestMergeHandle extends BaseTestHandle {
validateSecondaryIndexStatsContent(writeStatus, numUpdates, numDeletes);
}
+ @Test
+ public void testSortedMergeHandleWritesBinaryKeysInUtf8Order() throws
Exception {
+ // Drives HoodieSortedMergeHandle directly against a Parquet base file
(requireSortedRecords() is
+ // false), validating comparator ordering only; does not cover the
HoodieMergeHandleFactory
+ // selection path for HFILE base-format tables.
+ // delete and recreate
+ metaClient.getStorage().deleteDirectory(metaClient.getBasePath());
+ Properties properties = new Properties();
+ properties.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "_row_key");
+ properties.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(),
"partition_path");
+ properties.put(HoodieWriteConfig.PRECOMBINE_FIELD_NAME.key(),
ORDERING_FIELD);
+ initMetaClient(getTableType(), properties);
+
+ HoodieWriteConfig config = getHoodieWriteConfigBuilder().build();
+ HoodieSparkTable.create(config, new HoodieLocalEngineContext(storageConf),
metaClient);
+
+ String partitionPath = HoodieTestDataGenerator.DEFAULT_PARTITION_PATHS[0];
+ HoodieTestDataGenerator dataGenerator = new HoodieTestDataGenerator(new
String[] {partitionPath});
+
+ // These two keys sort in opposite order under UTF-16 (String#compareTo)
vs UTF-8 bytes (HFile/MDT order).
+ String supplementaryKey = "😀_record";
+ String bmpHighKey = new String(Character.toChars(0xFFFD)) + "_record";
+ assertTrue(supplementaryKey.compareTo(bmpHighKey) < 0);
+ assertTrue(StringUtils.compareUtf8Bytes(bmpHighKey, supplementaryKey) < 0);
+
+ // Base file has the UTF-8-smaller key.
+ List<HoodieRecord> baseRecords =
withRowKey(dataGenerator.generateInserts("000", 1), bmpHighKey, partitionPath);
+ SparkRDDWriteClient client = getHoodieWriteClient(config);
+ String instantTime = client.startCommit();
+ JavaRDD<WriteStatus> statuses = client.upsert(jsc.parallelize(baseRecords,
1), instantTime);
+ client.commit(instantTime, statuses, Option.empty(), COMMIT_ACTION,
Collections.emptyMap(), Option.empty());
+
+ metaClient = HoodieTableMetaClient.reload(metaClient);
+ HoodieSparkCopyOnWriteTable table = (HoodieSparkCopyOnWriteTable)
HoodieSparkCopyOnWriteTable.create(config, context, metaClient);
+ HoodieFileGroup fileGroup =
table.getFileSystemView().getAllFileGroups(partitionPath).collect(Collectors.toList()).get(0);
+ String fileId = fileGroup.getFileGroupId().getFileId();
+
+ // Merge the UTF-8-larger key in directly via HoodieSortedMergeHandle.
+ List<HoodieRecord> newRecords =
withRowKey(dataGenerator.generateInserts("001", 1), supplementaryKey,
partitionPath);
+ HoodieSortedMergeHandle mergeHandle = new HoodieSortedMergeHandle(
+ config, "001", table, newRecords.iterator(), partitionPath, fileId,
new LocalTaskContextSupplier(), Option.empty());
+ mergeHandle.doMerge();
+ WriteStatus writeStatus = (WriteStatus) mergeHandle.close().get(0);
+
+ String fullPath = metaClient.getBasePath() + "/" +
writeStatus.getStat().getPath();
+ List<GenericRecord> actualRecords = new
ParquetUtils().readAvroRecords(metaClient.getStorage(), new
StoragePath(fullPath));
+ List<String> actualKeysInOrder = actualRecords.stream().map(r ->
r.get("_row_key").toString()).collect(Collectors.toList());
+ // bmpHighKey must come first when sorted by UTF-8 bytes.
+ assertEquals(Arrays.asList(bmpHighKey, supplementaryKey),
actualKeysInOrder);
+ }
+
@Test
void testWriteFailures() throws Exception {
// delete and recreate
@@ -603,6 +656,12 @@ public class TestMergeHandle extends BaseTestHandle {
}).collect(Collectors.toList());
}
+ private List<HoodieRecord> withRowKey(List<HoodieRecord> records, String
rowKey, String partitionPath) {
+ GenericRecord genericRecord = (GenericRecord) ((SerializableIndexedRecord)
records.get(0).getData()).getData();
+ genericRecord.put("_row_key", rowKey);
+ return getHoodieRecords(OverwriteWithLatestAvroPayload.class.getName(),
Collections.singletonList(genericRecord), partitionPath, false);
+ }
+
private void setCurLocation(List<HoodieRecord> records, String fileId,
String instantTime) {
records.forEach(record -> record.setCurrentLocation(new
HoodieRecordLocation(instantTime, fileId)));
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala
index 7d6640b6ea6d..370b0f7467c5 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSecondaryIndexPruning.scala
@@ -230,6 +230,78 @@ class TestSecondaryIndexPruning extends
SparkClientFunctionalTestHarnessScala {
}
}
+ @Test
+ def testSecondaryIndexWithNonAsciiSecondaryKeyValues(): Unit = {
+ var hudiOpts = commonOpts
+ hudiOpts = hudiOpts ++ Map(
+ DataSourceWriteOptions.TABLE_TYPE.key -> COW_TABLE_TYPE_OPT_VAL,
+ DataSourceReadOptions.ENABLE_DATA_SKIPPING.key -> "true")
+ tableName += "test_secondary_index_non_ascii_partitioned_cow"
+
+ // These two secondary values have UTF-16 order reversed vs their raw
UTF-8 byte order:
+ // U+E000 encodes to bytes EE 80 80 while U+20000 encodes to F0 A0 80 80,
so U+E000 sorts
+ // before U+20000 by UTF-8 bytes; but in UTF-16 the U+20000 surrogate pair
(D840 DC00)
+ // sorts before the single U+E000 code unit. The fix orders metadata keys
by UTF-8 bytes,
+ // matching HFile, so both lookups must still resolve to the correct row.
+ val bmpVal = new String(Character.toChars(0xE000)) + "acme"
+ val astralVal = new String(Character.toChars(0x20000)) + "acme"
+ val asciiVal = "acme"
+
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | ts bigint,
+ | record_key_col string,
+ | not_record_key_col string,
+ | partition_key_col string
+ |) using hudi
+ | options (
+ | primaryKey ='record_key_col',
+ | type = 'cow',
+ | hoodie.metadata.enable = 'true',
+ | hoodie.metadata.record.index.enable = 'true',
+ | hoodie.datasource.write.recordkey.field = 'record_key_col',
+ | hoodie.enable.data.skipping = 'true',
+ | hoodie.datasource.write.payload.class =
"org.apache.hudi.common.model.OverwriteWithLatestAvroPayload"
+ | )
+ | partitioned by(partition_key_col)
+ | location '$basePath'
+ """.stripMargin)
+ // small file limit 0 so each insert lands in its own file, giving data
skipping something to prune
+ withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+ spark.sql(s"insert into $tableName values(1, 'row1', '$bmpVal', 'p1')")
+ spark.sql(s"insert into $tableName values(2, 'row2', '$astralVal',
'p2')")
+ spark.sql(s"insert into $tableName values(3, 'row3', '$asciiVal', 'p3')")
+ // create secondary index on the column holding the non-ascii values
+ spark.sql(s"create index idx_not_record_key_col on $tableName
(not_record_key_col)")
+ metaClient = HoodieTableMetaClient.builder()
+ .setBasePath(basePath)
+ .setConf(HoodieTestUtils.getDefaultStorageConf)
+ .build()
+
assert(metaClient.getTableConfig.getMetadataPartitions.contains("secondary_index_idx_not_record_key_col"))
+ // non-ascii secondary values must be preserved verbatim in the
secondary index records
+ checkAnswer(s"select key from hudi_metadata('$basePath') where type=7")(
+ Seq(bmpVal + SECONDARY_INDEX_RECORD_KEY_SEPARATOR + "row1"),
+ Seq(astralVal + SECONDARY_INDEX_RECORD_KEY_SEPARATOR + "row2"),
+ Seq(asciiVal + SECONDARY_INDEX_RECORD_KEY_SEPARATOR + "row3")
+ )
+ withSQLConf("hoodie.metadata.enable" -> "true",
+ "hoodie.enable.data.skipping" -> "true",
+ "hoodie.fileIndex.dataSkippingFailureMode" -> "strict") {
+ // each non-ascii equality predicate must resolve to exactly its own
row via the SI prefix lookup
+ checkAnswer(s"select ts, record_key_col, not_record_key_col,
partition_key_col from $tableName where not_record_key_col = '$bmpVal'")(
+ Seq(1, "row1", bmpVal, "p1")
+ )
+ checkAnswer(s"select ts, record_key_col, not_record_key_col,
partition_key_col from $tableName where not_record_key_col = '$astralVal'")(
+ Seq(2, "row2", astralVal, "p2")
+ )
+ // data skipping must prune files using the non-ascii secondary keys
+ verifyFilePruning(hudiOpts, EqualTo(attribute("not_record_key_col"),
Literal(bmpVal)))
+ verifyFilePruning(hudiOpts, EqualTo(attribute("not_record_key_col"),
Literal(astralVal)))
+ }
+ }
+ }
+
@Test
def testCreateAndDropSecondaryIndex(): Unit = {
var hudiOpts = commonOpts