This is an automated email from the ASF dual-hosted git repository.
sunchao pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 789106b01b72 [SPARK-58949][CORE] Use unsigned UTF-8 byte ordering for
Variant object fields
789106b01b72 is described below
commit 789106b01b72aa7d1c6f424eaa077fd592876a72
Author: peterxcli <[email protected]>
AuthorDate: Mon Aug 24 09:34:56 2026 -0700
[SPARK-58949][CORE] Use unsigned UTF-8 byte ordering for Variant object
fields
### What changes were proposed in this pull request?
This PR fixes
[SPARK-58949](https://issues.apache.org/jira/browse/SPARK-58949) by:
- sorting newly written Variant object fields by unsigned lexicographic
UTF-8 bytes, as required by the [Variant encoding
specification](https://github.com/apache/parquet-format/blob/24102ed5c56e51b610a4897e5f79e76e43732d1d/VariantEncoding.md#L449-L463);
- using binary search for object lookup at every object size and comparing
UTF-8-encoded keys using unsigned byte ordering;
- retrying lookup with Java UTF-16 order when needed so values written by
older Spark versions remain readable; and
- accepting both canonical UTF-8 order and legacy UTF-16 order during
schema validation, while preserving the established schema field order.
This follows the compatibility direction discussed in
[apache/parquet-java#3736](https://github.com/apache/parquet-java/pull/3736).
### Why are the changes needed?
The Variant specification orders object keys by unsigned UTF-8 bytes, but
Spark used String.compareTo, which orders UTF-16 code units. These orders
differ for some valid keys. For example, UTF-16 places U+10000 before U+FFFF,
while unsigned UTF-8 places U+FFFF first.
As a result, Spark wrote non-canonical Variant objects and could miss
fields when binary-searching canonical values produced by another
implementation.
### Does this PR introduce _any_ user-facing change?
Yes. Newly written Variant objects use the specification's unsigned UTF-8
field order. Spark continues to read affected values written in the legacy
UTF-16 order, and schema output keeps its existing field order.
### How was this patch tested?
Added regressions for canonical and legacy object lookup, nested objects,
schema_of_variant, and Parquet shredding-schema inference:
build/sbt \
'catalyst/testOnly *VariantExpressionSuite -- -z "SPARK-58949"' \
'sql/testOnly *VariantInferShreddingSuite -- -z "SPARK-58949"'
Both suites passed (1 test each). The affected modules also passed Java
checkstyle and main/test scalastyle.
I also ran a temporary lookup microbenchmark on an Apple M4 with Zulu
OpenJDK 21.0.6, comparing upstream/master at 9da9f8d6739 with this patch at
b3a050b3dc0. Each result is the median of three alternating JVM fork medians.
Each fork used a fixed 2 GiB heap, 5 seconds of warmup per case, and 9 measured
rounds of 5,000,000 lookups. Object construction was excluded. Lower is better.
| Object / lookup | master (ns/op) | patch (ns/op) | Change |
| --- | ---: | ---: | ---: |
| 16 fields, ASCII present | 193.7 | 94.6 | -51.2% |
| 16 fields, ASCII absent | 180.3 | 87.2 | -51.6% |
| 256 fields, canonical present | 102.2 | 140.6 | +37.6% |
| 256 fields, canonical absent | 111.5 | 147.0 | +31.8% (approx.) |
| 256 fields, legacy fallback present | 196.5 | 366.3 | +86.4% |
| 256 fields, legacy fallback absent | 201.7 | 367.0 | +82.0% |
The correctness assertion for a canonical U+10000 lookup changes from false
on master to true with this patch. Following parquet-java's implementation,
each canonical binary-search probe decodes the metadata key and re-encodes it
as UTF-8 instead of maintaining a separate raw-metadata comparator. This keeps
the implementation simple but adds allocations: the measured 256-field
canonical cases regress by 32-38%, and the legacy fallback cases regress by
82-86%. The 16-field cases improv [...]
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: OpenAI Codex (GPT-5)
Closes #58239 from peterxcli/SPARK-58949-variant-utf8-order.
Authored-by: peterxcli <[email protected]>
Signed-off-by: Chao Sun <[email protected]>
(cherry picked from commit 2a61a79bbb73b46cde33e81b9916f6d47e76efe2)
Signed-off-by: Chao Sun <[email protected]>
---
.../org/apache/spark/types/variant/Variant.java | 26 ++++++-----
.../apache/spark/types/variant/VariantBuilder.java | 12 ++++-
.../apache/spark/types/variant/VariantUtil.java | 10 +++++
.../expressions/variant/variantExpressions.scala | 21 ++++++---
.../variant/VariantExpressionSuite.scala | 51 ++++++++++++++++++++++
.../parquet/InferVariantShreddingSchema.scala | 25 +++++++----
.../parquet/VariantInferShreddingSuite.scala | 39 +++++++++++++++++
7 files changed, 156 insertions(+), 28 deletions(-)
diff --git
a/common/variant/src/main/java/org/apache/spark/types/variant/Variant.java
b/common/variant/src/main/java/org/apache/spark/types/variant/Variant.java
index 8996caeb364c..cefb957f9b97 100644
--- a/common/variant/src/main/java/org/apache/spark/types/variant/Variant.java
+++ b/common/variant/src/main/java/org/apache/spark/types/variant/Variant.java
@@ -144,18 +144,17 @@ public final class Variant {
// It is only legal to call it when `getType()` is `Type.OBJECT`.
public Variant getFieldByKey(String key) {
return handleObject(value, pos, (size, idSize, offsetSize, idStart,
offsetStart, dataStart) -> {
- // Use linear search for a short list. Switch to binary search when the
length reaches
- // `BINARY_SEARCH_THRESHOLD`.
- final int BINARY_SEARCH_THRESHOLD = 32;
- if (size < BINARY_SEARCH_THRESHOLD) {
- for (int i = 0; i < size; ++i) {
- int id = readUnsigned(value, idStart + idSize * i, idSize);
- if (key.equals(getMetadataKey(metadata, id))) {
- int offset = readUnsigned(value, offsetStart + offsetSize * i,
offsetSize);
- return new Variant(value, metadata, dataStart + offset);
- }
+ byte[] keyBytes = encodeKey(key);
+ int numAttempts = 1;
+ // UTF-8 and UTF-16 order can differ only for keys with a code unit at
or above U+D800.
+ for (int i = 0; i < key.length(); ++i) {
+ if (key.charAt(i) >= Character.MIN_SURROGATE) {
+ numAttempts = 2;
+ break;
}
- } else {
+ }
+ // Search the spec's UTF-8 order first, then the UTF-16 order written by
older Spark versions.
+ for (int attempt = 0; attempt < numAttempts; ++attempt) {
int low = 0;
int high = size - 1;
while (low <= high) {
@@ -164,7 +163,10 @@ public final class Variant {
// overflows int.
int mid = (low + high) >>> 1;
int id = readUnsigned(value, idStart + idSize * mid, idSize);
- int cmp = getMetadataKey(metadata, id).compareTo(key);
+ String midKey = getMetadataKey(metadata, id);
+ int cmp = attempt == 0
+ ? compareKeys(encodeKey(midKey), keyBytes)
+ : midKey.compareTo(key);
if (cmp < 0) {
low = mid + 1;
} else if (cmp > 0) {
diff --git
a/common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java
b/common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java
index d9eed06bf3a6..ef00c8379b89 100644
---
a/common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java
+++
b/common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java
@@ -376,7 +376,7 @@ public class VariantBuilder {
} else {
id = dictionaryKeys.size();
dictionary.put(key, id);
- dictionaryKeys.add(key.getBytes(StandardCharsets.UTF_8));
+ dictionaryKeys.add(encodeKey(key));
}
return id;
}
@@ -994,6 +994,7 @@ public class VariantBuilder {
final String key;
final int id;
final int offset;
+ private byte[] keyBytes;
public FieldEntry(String key, int id, int offset) {
this.key = key;
@@ -1005,9 +1006,16 @@ public class VariantBuilder {
return new FieldEntry(key, id, newOffset);
}
+ private byte[] keyBytes() {
+ if (keyBytes == null) {
+ keyBytes = encodeKey(key);
+ }
+ return keyBytes;
+ }
+
@Override
public int compareTo(FieldEntry other) {
- return key.compareTo(other.key);
+ return compareKeys(keyBytes(), other.keyBytes());
}
}
diff --git
a/common/variant/src/main/java/org/apache/spark/types/variant/VariantUtil.java
b/common/variant/src/main/java/org/apache/spark/types/variant/VariantUtil.java
index 681c4038a9a9..184a7b29a978 100644
---
a/common/variant/src/main/java/org/apache/spark/types/variant/VariantUtil.java
+++
b/common/variant/src/main/java/org/apache/spark/types/variant/VariantUtil.java
@@ -688,6 +688,16 @@ public class VariantUtil {
}
}
+ // Encode an object field key for comparison in the order required by the
Variant spec.
+ public static byte[] encodeKey(String key) {
+ return key.getBytes(StandardCharsets.UTF_8);
+ }
+
+ // Compare UTF-8-encoded object field keys using unsigned lexicographic byte
ordering.
+ public static int compareKeys(byte[] left, byte[] right) {
+ return Arrays.compareUnsigned(left, right);
+ }
+
// Get a key at `id` in the variant metadata.
// Throw `MALFORMED_VARIANT` if the variant is malformed. An out-of-bound
`id` is also considered
// a malformed variant because it is read from the corresponding variant
value.
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/variant/variantExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/variant/variantExpressions.scala
index fddb88d95395..c58634cfd8d4 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/variant/variantExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/variant/variantExpressions.scala
@@ -1933,13 +1933,24 @@ object SchemaOfVariant {
val field = v.getFieldAtIndex(i)
fields(i) = StructField(field.key, schemaOf(field.value))
}
- // According to the variant spec, object fields must be sorted
alphabetically. So we don't
- // have to sort, but just need to validate they are sorted.
+ var utf8Sorted = true
+ var utf16Sorted = true
+ var previousKey = if (size > 0) fields(0).name else null
+ var previousKeyBytes = if (size > 0) VariantUtil.encodeKey(previousKey)
else null
for (i <- 1 until size) {
- if (fields(i - 1).name >= fields(i).name) {
- throw new SparkRuntimeException("MALFORMED_VARIANT", Map.empty)
- }
+ val currentKey = fields(i).name
+ val currentKeyBytes = VariantUtil.encodeKey(currentKey)
+ utf8Sorted &&= VariantUtil.compareKeys(previousKeyBytes,
currentKeyBytes) < 0
+ utf16Sorted &&= previousKey.compareTo(currentKey) < 0
+ previousKey = currentKey
+ previousKeyBytes = currentKeyBytes
+ }
+ if (!utf8Sorted && !utf16Sorted) {
+ throw new SparkRuntimeException("MALFORMED_VARIANT", Map.empty)
}
+ // `mergeSchema` expects StructType fields in Java String order. Older
Spark values already
+ // use that order, while spec-compliant values need to be reordered
after validation.
+ java.util.Arrays.sort(fields, JsonInferSchema.structFieldComparator)
StructType(fields)
case Type.ARRAY =>
var elementType: DataType = NullType
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/variant/VariantExpressionSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/variant/VariantExpressionSuite.scala
index c69ad520a241..ed790e643adc 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/variant/VariantExpressionSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/variant/VariantExpressionSuite.scala
@@ -548,6 +548,57 @@ class VariantExpressionSuite extends SparkFunSuite with
ExpressionEvalHelper {
testVariantGet(json, "$." + numKeys, IntegerType, null)
}
+ test("SPARK-58949: object keys use unsigned UTF-8 order") {
+ val bmpKey = new String(Character.toChars(65535))
+ val supplementaryKey = new String(Character.toChars(0x10000))
+ val quote = 34.toChar.toString
+ val asciiFields = (0 until 32).map(i => quote + i + quote + ":" + i)
+ val objectJson = (asciiFields ++ Seq(
+ quote + supplementaryKey + quote + ":99",
+ quote + bmpKey + quote + ":98")).mkString("{", ",", "}")
+
+ val variant = VariantBuilder.parseJson(objectJson, false)
+ assert(variant.getFieldAtIndex(32).key === bmpKey)
+ assert(variant.getFieldAtIndex(33).key === supplementaryKey)
+ assert(variant.getFieldByKey(bmpKey).getLong === 98L)
+ assert(variant.getFieldByKey(supplementaryKey).getLong === 99L)
+ assert(variant.getFieldByKey("missing") === null)
+
+ val nestedJson = "{" + quote + "nested" + quote + ":" + objectJson + "}"
+ val nested = VariantBuilder.parseJson(nestedJson, false)
+ .getFieldByKey("nested")
+ assert(nested.getFieldAtIndex(32).key === bmpKey)
+ assert(nested.getFieldByKey(supplementaryKey).getLong === 99L)
+
+ // Reorder the last two field entries to reproduce the UTF-16 order
written by older Spark.
+ val legacyValue = variant.getValue.clone()
+ handleObject[Unit](legacyValue, 0,
+ (size, idSize, offsetSize, idStart, offsetStart, _dataStart) => {
+ def swap(start: Int, width: Int): Unit = {
+ val left = start + (size - 2) * width
+ val right = left + width
+ val leftValue = readUnsigned(legacyValue, left, width)
+ val rightValue = readUnsigned(legacyValue, right, width)
+ writeLong(legacyValue, left, rightValue, width)
+ writeLong(legacyValue, right, leftValue, width)
+ }
+ swap(idStart, idSize)
+ swap(offsetStart, offsetSize)
+ })
+ val legacy = new Variant(legacyValue, variant.getMetadata)
+ assert(legacy.getFieldAtIndex(32).key === supplementaryKey)
+ assert(legacy.getFieldByKey("31").getLong === 31L)
+ assert(legacy.getFieldByKey(bmpKey).getLong === 98L)
+ assert(legacy.getFieldByKey("missing") === null)
+
+ val expectedSchemaNames = ((0 until 32).map(_.toString).sorted ++
+ Seq(supplementaryKey, bmpKey)).toArray
+ Seq(variant, legacy).foreach { v =>
+ val schema = SchemaOfVariant.schemaOf(v).asInstanceOf[StructType]
+ assert(schema.fieldNames === expectedSchemaNames)
+ }
+ }
+
test("variant_get timestamp") {
DateTimeTestUtils.outstandingZoneIds.foreach { zid =>
withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> zid.getId) {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/InferVariantShreddingSchema.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/InferVariantShreddingSchema.scala
index bbfbbfde0ba4..98385c796d85 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/InferVariantShreddingSchema.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/InferVariantShreddingSchema.scala
@@ -365,16 +365,23 @@ class InferVariantShreddingSchema(val schema: StructType)
{
v.getType match {
case Type.OBJECT =>
val size = v.objectSize()
- // Validate fields are sorted (per variant spec)
+ var utf8Sorted = true
+ var utf16Sorted = true
+ var previousKey = if (size > 0) v.getFieldAtIndex(0).key else null
+ var previousKeyBytes = if (size > 0)
VariantUtil.encodeKey(previousKey) else null
for (i <- 1 until size) {
- val prevKey = v.getFieldAtIndex(i - 1).key
- val currKey = v.getFieldAtIndex(i).key
- if (prevKey >= currKey) {
- throw new SparkRuntimeException(
- errorClass = "MALFORMED_VARIANT",
- messageParameters = Map.empty
- )
- }
+ val currentKey = v.getFieldAtIndex(i).key
+ val currentKeyBytes = VariantUtil.encodeKey(currentKey)
+ utf8Sorted &&= VariantUtil.compareKeys(previousKeyBytes,
currentKeyBytes) < 0
+ utf16Sorted &&= previousKey.compareTo(currentKey) < 0
+ previousKey = currentKey
+ previousKeyBytes = currentKeyBytes
+ }
+ if (!utf8Sorted && !utf16Sorted) {
+ throw new SparkRuntimeException(
+ errorClass = "MALFORMED_VARIANT",
+ messageParameters = Map.empty
+ )
}
// Process each field
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/VariantInferShreddingSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/VariantInferShreddingSuite.scala
index cbd3d89c3658..588d0352627b 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/VariantInferShreddingSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/VariantInferShreddingSuite.scala
@@ -764,6 +764,45 @@ class VariantInferShreddingSuite extends
SharedSparkSession with ParquetTest {
checkAnswer(spark.read.parquet(dir.getAbsolutePath), df.collect())
}
+ testWithTempDir(
+ "SPARK-58949: infer shredding schema from canonical and legacy fields")
{ dir =>
+ val bmpKey = new String(Character.toChars(65535))
+ val supplementaryKey = new String(Character.toChars(0x10000))
+ val quote = 34.toChar.toString
+ val json = "{" + quote + supplementaryKey + quote + ":1," +
+ quote + bmpKey + quote + ":2}"
+ val canonical = VariantBuilder.parseJson(json, false)
+ val legacyValue = canonical.getValue.clone()
+ VariantUtil.handleObject[Unit](legacyValue, 0,
+ (size, idSize, offsetSize, idStart, offsetStart, _dataStart) => {
+ def swap(start: Int, width: Int): Unit = {
+ val left = start + (size - 2) * width
+ val right = left + width
+ val leftValue = VariantUtil.readUnsigned(legacyValue, left, width)
+ val rightValue = VariantUtil.readUnsigned(legacyValue, right, width)
+ VariantUtil.writeLong(legacyValue, left, rightValue, width)
+ VariantUtil.writeLong(legacyValue, right, leftValue, width)
+ }
+ swap(idStart, idSize)
+ swap(offsetStart, offsetSize)
+ })
+ val canonicalValue = canonical.getValue
+ val metadata = canonical.getMetadata
+ val rdd = spark.sparkContext.parallelize(0 until 20, 1).map { i =>
+ val value = if (i % 2 == 0) canonicalValue else legacyValue
+ InternalRow(new VariantVal(value, metadata))
+ }
+ val writeSchema = DataType.fromDDL("struct<v
variant>").asInstanceOf[StructType]
+ val df = Dataset.ofRows(spark,
LogicalRDD(DataTypeUtils.toAttributes(writeSchema), rdd)(spark))
+
+ df.write.mode("overwrite").parquet(dir.getAbsolutePath)
+ val expected = StructType(Seq(
+ StructField(supplementaryKey, LongType),
+ StructField(bmpKey, LongType)))
+ checkFileSchema(expected, dir)
+ assert(spark.read.parquet(dir.getAbsolutePath).count() === 20)
+ }
+
testWithTempDir("special characters in field names - dots") { dir =>
val df = spark.sql(
"""
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]