spark-test commented on code in PR #59669:
URL: https://github.com/apache/spark/pull/59669#discussion_r4481813531
##########
common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java:
##########
@@ -205,6 +205,225 @@ public static Variant pickAtPaths(Variant v, PickNode
tree) {
return builder.result();
}
+ /**
+ * Return {@code v} in canonical form, or {@code v} itself if it is already
canonical.
+ * <p>
+ * The variant binary encoding allows the same logical value to be encoded
in several ways
+ * (dictionary order, unused keys, integer and offset widths, decimal scale,
...). Canonical form
+ * picks one encoding per value, so that values which are equal under the
variant equality rules
+ * have identical {@code value} and {@code metadata} bytes and can be
compared, hashed and
+ * grouped bytewise. It is a Spark-internal normalization, not part of the
variant spec, and
+ * nothing outside Spark is expected to produce or require it.
+ * <p>
+ * A variant is canonical when all of the following hold.
+ * <ul>
+ * <li>Metadata header: version {@link VariantUtil#VERSION} and the
minimal offset size for
Review Comment:
**Nit (P3):** The Javadoc further down says `isCanonical` checks exactly
these rules, but the header rules are incomplete. `isCanonical` compares the
whole metadata header byte with `metadataHeader(canonicalOffsetSize)` (line
281), which leaves `sorted_strings` and the unused bit 5 clear. It also
compares whole container header bytes with `objectHeader`/`arrayHeader` (lines
352-353 and 370-371), which leave the reserved bits clear: bit 7 for objects,
and bits 5-7 for arrays. So metadata that meets every rule listed here but sets
`sorted_strings`, as a spec-conformant writer may for exactly such a sorted,
unique dictionary, still gets rebuilt. The 'metadata flags and trailing bytes'
and 'reserved container header bits' tests pin both behaviors, but the
documented rules predict neither. A maintainer following the list, for example
by setting `sorted_strings` for canonical dictionaries in `result()`, would
break the `isCanonical`/`doCanonicalize` agreement that this Javadoc says must
be kept. Stating that canonical headers keep these bits clear would also
cover the earlier request to document every header requirement.
This extends an [existing
discussion](https://github.com/apache/spark/pull/59669#discussion_r4419570081).
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/variant/VariantExpressionEvalUtils.scala:
##########
@@ -76,6 +76,21 @@ object VariantExpressionEvalUtils {
def isValidVariant(input: VariantVal): Boolean =
VariantUtil.isValidVariant(input.getValue, input.getMetadata)
+ def canonicalizeVariant(input: VariantVal): VariantVal = {
+ val variant = new Variant(input.getValue, input.getMetadata)
+ val canonicalized = try {
+ VariantBuilder.canonicalize(variant)
+ } catch {
+ case _: VariantSizeLimitException =>
+ throw QueryExecutionErrors.variantConstructorSizeLimitError()
Review Comment:
**Nit (P3):** `VARIANT_CONSTRUCTOR_SIZE_LIMIT` has a fixed message, 'Cannot
construct a Variant larger than 16 MiB. The maximum allowed size of a Variant
value is 16 MiB.', but outside tests `SIZE_LIMIT` is 128 MiB
(`VariantUtil.java:150-153`). The canonical rebuild can outgrow an input the
constructor already accepted, either by expanding shared payloads or by
widening field IDs after the keys are re-sorted. In production, that overflow
would tell a user whose input may already be larger than 16 MiB that no variant
may exceed 16 MiB. The comment on `variantConstructorSizeLimit` also reserves
this error for oversized bytes from another tool reaching the `Variant`
constructor. The constructor's own message has said 16 MiB since before this
PR. What's new is that a Spark-side rebuild overflow now lands there too.
`parse_json`, `JacksonParser`, `variant_insert`, `variant_set` and
`variant_array_append` all report `VARIANT_SIZE_LIMIT` with the enforced limit.
The 'canonicalize_variant
size limit' test runs at the 16 MiB testing limit, so it can't see the
difference.
A dedicated error condition could keep the internal function name out of the
error, as requested earlier in review, and still report the real limit. It
would take a `sizeLimit` parameter, formatted the way `variantSizeLimitError`
formats it, use SQLSTATE 22023, and replace `variantConstructorSizeLimitError`
here.
**Verification:**
- **Behavior:** Canonicalizing a value whose shared payload expands past the
size limit raises the new error condition, with the enforced limit as its
sizeLimit parameter and no function name.
- **Compatibility:** Oversized value or metadata passed to the Variant
constructor still raises VARIANT_CONSTRUCTOR_SIZE_LIMIT, and the builder-backed
variant functions still raise VARIANT_SIZE_LIMIT with their own function names.
##########
common/variant/src/test/scala/org/apache/spark/types/variant/VariantCanonicalizeSuite.scala:
##########
@@ -0,0 +1,860 @@
+/*
+ * 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.spark.types.variant
+
+import java.nio.charset.StandardCharsets
+
+import scala.util.Random
+
+import org.scalatest.funsuite.AnyFunSuite // scalastyle:ignore funsuite
+
+import org.apache.spark.SparkRuntimeException
+import org.apache.spark.types.variant.VariantUtil._
+
+class VariantCanonicalizeSuite extends AnyFunSuite { // scalastyle:ignore
funsuite
+ private val emptyMetadata = Array[Byte](VERSION, 0, 0)
+ private val nullValue = Array(primitiveHeader(NULL))
+ private val trueValue = Array(primitiveHeader(TRUE))
+ private val falseValue = Array(primitiveHeader(FALSE))
+
+ private def bytes(values: Int*): Array[Byte] = values.map(_.toByte).toArray
+
+ private def utf8(value: String): Array[Byte] =
value.getBytes(StandardCharsets.UTF_8)
+
+ private def littleEndian(value: BigInt, width: Int): Array[Byte] =
+ Array.tabulate(width)(index => ((value >> (8 * index)) & 0xff).toByte)
+
+ private def concatenate(parts: Seq[Array[Byte]]): Array[Byte] =
+ parts.iterator.flatMap(_.iterator).toArray
+
+ private def integer(value: Long, typeInfo: Int = INT1, width: Int = 1):
Array[Byte] =
+ Array(primitiveHeader(typeInfo)) ++ littleEndian(BigInt(value), width)
+
+ private def decimal(
+ unscaled: String,
+ scale: Int,
+ typeInfo: Int,
+ width: Int): Array[Byte] = {
+ Array(primitiveHeader(typeInfo), scale.toByte) ++
littleEndian(BigInt(unscaled), width)
+ }
+
+ private def floating(rawBits: Long, typeInfo: Int, width: Int): Array[Byte] =
+ Array(primitiveHeader(typeInfo)) ++ littleEndian(BigInt(rawBits), width)
+
+ private def string(value: Array[Byte], longEncoding: Boolean = false):
Array[Byte] = {
+ if (longEncoding) {
+ Array(primitiveHeader(LONG_STR)) ++ littleEndian(BigInt(value.length),
4) ++ value
+ } else {
+ Array(shortStrHeader(value.length)) ++ value
+ }
+ }
+
+ private def metadata(
+ keys: Seq[Array[Byte]],
+ offsetSize: Int = 1,
+ flags: Int = 0): Array[Byte] = {
+ val offsets = keys.scanLeft(0)((offset, key) => offset + key.length)
+ bytes(metadataHeader(offsetSize) | flags) ++
littleEndian(BigInt(keys.size), offsetSize) ++
+ concatenate(offsets.map(offset => littleEndian(BigInt(offset),
offsetSize))) ++
+ concatenate(keys)
+ }
+
+ private def dictionary(names: String*): Array[Byte] =
metadata(names.map(utf8))
+
+ private def array(
+ elements: Seq[Array[Byte]],
+ offsetSize: Int = 1,
+ largeSize: Boolean = false): Array[Byte] = {
+ val offsets = elements.scanLeft(0)((offset, element) => offset +
element.length)
+ Array(arrayHeader(largeSize, offsetSize)) ++
+ littleEndian(BigInt(elements.size), if (largeSize) 4 else 1) ++
+ concatenate(offsets.map(offset => littleEndian(BigInt(offset),
offsetSize))) ++
+ concatenate(elements)
+ }
+
+ private def obj(
+ fields: Seq[(Int, Array[Byte])],
+ idSize: Int = 1,
+ offsetSize: Int = 1,
+ largeSize: Boolean = false,
+ physicalOrder: Seq[Int] = Seq.empty): Array[Byte] = {
+ val order = if (physicalOrder.isEmpty) fields.indices else physicalOrder
+ val offsets = new Array[Int](fields.size)
+ var dataSize = 0
+ order.foreach { fieldIndex =>
+ offsets(fieldIndex) = dataSize
+ dataSize += fields(fieldIndex)._2.length
+ }
+ Array(objectHeader(largeSize, idSize, offsetSize)) ++
+ littleEndian(BigInt(fields.size), if (largeSize) 4 else 1) ++
+ concatenate(fields.map { case (keyId, _) => littleEndian(BigInt(keyId),
idSize) }) ++
+ concatenate((offsets.toSeq :+ dataSize).map(offset =>
+ littleEndian(BigInt(offset), offsetSize))) ++
+ concatenate(order.map(fieldIndex => fields(fieldIndex)._2))
+ }
+
+ private def assertBytes(actual: Array[Byte], expected: Array[Byte], label:
String): Unit = {
+ assert(actual.sameElements(expected),
+ s"$label: expected ${expected.length} bytes, got ${actual.length} bytes;
" +
+ s"expected prefix ${expected.take(32).mkString(",")}, " +
+ s"actual prefix ${actual.take(32).mkString(",")}")
+ }
+
+ // The input is canonical exactly when it already has the expected bytes.
+ private def check(
+ input: Variant,
+ expectedValue: Array[Byte],
+ expectedMetadata: Array[Byte] = emptyMetadata): Variant = {
+ val canonicalInput = input.pos == 0 &&
input.value.sameElements(expectedValue) &&
+ input.metadata.sameElements(expectedMetadata)
+ val originalValue = input.value.clone()
+ val originalMetadata = input.metadata.clone()
+ assert(VariantBuilder.isCanonical(input) == canonicalInput)
+ val actual = VariantBuilder.canonicalize(input)
+ assert((actual eq input) == canonicalInput)
+ assert(actual.pos == 0)
+ assertBytes(actual.value, expectedValue, "canonical value")
+ assertBytes(actual.metadata, expectedMetadata, "canonical metadata")
+ assert(VariantBuilder.isCanonical(actual))
+ assert(VariantBuilder.canonicalize(actual) eq actual)
+ Seq(input, actual).foreach { source =>
+ val rebuilt = VariantBuilder.doCanonicalize(source)
+ assertBytes(rebuilt.value, expectedValue, "unconditional rebuild value")
+ assertBytes(rebuilt.metadata, expectedMetadata, "unconditional rebuild
metadata")
+ }
+ assertBytes(input.value, originalValue, "unchanged input value")
+ assertBytes(input.metadata, originalMetadata, "unchanged input metadata")
+ actual
+ }
+
+ private def checkKeyless(inputValue: Array[Byte], expectedValue:
Array[Byte]): Unit =
+ check(new Variant(inputValue, emptyMetadata), expectedValue)
+
+ private def checkError(condition: String)(body: => Any): Unit = {
+ assert(intercept[SparkRuntimeException](body).getCondition == condition)
+ }
+
+ test("canonical encoding has pinned value and metadata bytes") {
+ checkKeyless(bytes(24, 1, 0, 0, 0, 0, 0, 0, 0), bytes(12, 1))
+ checkKeyless(bytes(64, 1, 0, 0, 0, 120), bytes(5, 120))
+ check(VariantBuilder.parseJson("{\"b\":2,\"a\":1}", false),
+ bytes(2, 2, 0, 1, 0, 2, 4, 12, 1, 12, 2), bytes(1, 2, 0, 1, 2, 97, 98))
+ checkKeyless(bytes(2, 0, 0), bytes(2, 0, 0))
+ checkKeyless(bytes(3, 0, 0), bytes(3, 0, 0))
+ }
+
+ test("metadata helpers encode and decode every offset-width header") {
+ Seq((1, 0x01), (2, 0x41), (3, 0x81), (4, 0xc1)).foreach {
+ case (width, header) =>
+ val input = bytes(header) ++ littleEndian(BigInt(2), width) ++
+ concatenate(Seq(0, 1, 2).map(offset => littleEndian(BigInt(offset),
width))) ++
+ bytes(97, 98)
+ assert(metadataHeader(width) == header.toByte)
+ assert(getMetadataOffsetSize(input) == width)
+ assert(getMetadataNumKeys(input, width) == 2)
+ assert(getMetadataStringStart(input, 2, width) == 1 + 4 * width)
+ assert(getMetadataKey(input, 0) == "a")
+ assert(getMetadataKey(input, 1) == "b")
+ }
+ }
+
+ test("isCanonical rejects a metadata last offset past the available string
bytes") {
+ val value = obj(Seq(0 -> integer(1)))
+ val validMetadata = dictionary("ab")
+ assert(VariantBuilder.isCanonical(new Variant(value, validMetadata)))
+
+ Seq(1, 2).foreach { missingBytes =>
+ val truncatedMetadata = validMetadata.dropRight(missingBytes)
+ assert(!VariantBuilder.isCanonical(new Variant(value,
truncatedMetadata)))
+ }
+ }
+
+ test("isCanonical rejects decreasing or out-of-bounds dictionary offsets") {
+ val value = obj(Seq(0 -> integer(1), 1 -> integer(2), 2 -> integer(3)))
+ assert(VariantBuilder.isCanonical(new Variant(value, dictionary("a", "b",
"c"))))
+
+ val decreasingOffsets = bytes(VERSION, 3, 0, 2, 1, 3) ++ utf8("abc")
+ val outOfBoundsOffset = bytes(VERSION, 3, 0, 4, 2, 3) ++ utf8("abc")
Review Comment:
**Nit (P3):** The bound `nextOffset > lastOffset` rejects this fixture at
the first key, but the test doesn't depend on it. Without the bound, the drop
from 4 to 2 fails the monotonicity check one key later, before any key
comparison runs, so the test still passes. No other fixture reaches the bound
either: the malformed-structure metadata cases fail the exact-length check
first, and the fuzz metadata is always well formed. As explained in the thread
on this check, the bound is what keeps the `Arrays.compareUnsigned` key
comparison inside the metadata. Without it, offsets `[0, 4, 4, 3]` over `abc`
reach that comparison with `toIndex` 10 on 9-byte metadata, and `isCanonical`,
and with it `canonicalize`, throws `ArrayIndexOutOfBoundsException` instead of
returning false. Any dictionary whose offsets stay non-decreasing up to an
out-of-range offset ending the second or a later key behaves the same way, for
example `[0, 1, 5, 3]`. To pin the bound, add a `[0, 4, 4, 3]` fixture paired
with this three-field object, so the test can also assert that `canonicalize`
fails with `MALFORMED_VARIANT`.
This extends existing discussions
[1](https://github.com/apache/spark/pull/59669#discussion_r4351197407),
[2](https://github.com/apache/spark/pull/59669#discussion_r4419505117).
See **Shared repair plan 2** in the review body.
##########
common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java:
##########
@@ -205,6 +205,225 @@ public static Variant pickAtPaths(Variant v, PickNode
tree) {
return builder.result();
}
+ /**
+ * Return {@code v} in canonical form, or {@code v} itself if it is already
canonical.
+ * <p>
+ * The variant binary encoding allows the same logical value to be encoded
in several ways
+ * (dictionary order, unused keys, integer and offset widths, decimal scale,
...). Canonical form
+ * picks one encoding per value, so that values which are equal under the
variant equality rules
+ * have identical {@code value} and {@code metadata} bytes and can be
compared, hashed and
+ * grouped bytewise. It is a Spark-internal normalization, not part of the
variant spec, and
+ * nothing outside Spark is expected to produce or require it.
+ * <p>
+ * A variant is canonical when all of the following hold.
+ * <ul>
+ * <li>Metadata header: version {@link VariantUtil#VERSION} and the
minimal offset size for
+ * {@code max(lastOffset, numKeys)}, i.e. 1, 2, 3 or 4 bytes as given
by
+ * {@code getIntegerSize}.</li>
+ * <li>Metadata dictionary: the first offset is 0, offsets are
non-decreasing, the key bytes
+ * end exactly at the last offset (no trailing bytes), keys are
strictly increasing under
+ * unsigned byte comparison (so they are unique), and every key is
referenced by at least
+ * one object field. Key bytes are preserved as is, including invalid
UTF-8.</li>
+ * <li>Value root: starts at position 0 and spans exactly {@code
value.length} bytes.</li>
+ * <li>Object: field ids strictly increasing (fields are stored in key
order and no key is
+ * repeated), field data stored contiguously in field order, the end
offset equal to the
+ * data size, and the header using the minimal id size for the largest
id, the minimal
+ * offset size for the data size, and {@code is_large} only for more
than 255 fields.</li>
+ * <li>Array: element order preserved, element data stored contiguously in
element order, the
+ * end offset equal to the data size, and the header using the minimal
offset size for the
+ * data size and {@code is_large} only for more than 255 elements.</li>
+ * <li>Integer: the smallest of INT1, INT2, INT4 and INT8 that holds the
value.</li>
+ * <li>Decimal: trailing zeros stripped and a negative scale raised to 0.
An integral decimal
+ * that fits in a signed 64-bit integer is stored as an integer
instead. Otherwise the
+ * smallest of DECIMAL4 (precision and scale at most 9), DECIMAL8 (at
most 18) and
+ * DECIMAL16 that holds the value.</li>
+ * <li>Float and double: -0.0 is stored as 0.0 and NaN as the canonical
NaN bit pattern of
+ * {@link Float#floatToIntBits} and {@link Double#doubleToLongBits}.
The type is kept, so
+ * a float and a double are never identical.</li>
+ * <li>String: at most {@link VariantUtil#MAX_SHORT_STR_SIZE} bytes uses
the short-string
+ * encoding, anything longer the long-string encoding. Bytes are
preserved as is, including
+ * invalid UTF-8.</li>
+ * <li>Every other type (null, boolean, date, timestamps, binary, uuid,
...) has a single
+ * encoding in the spec and is copied unchanged.</li>
+ * </ul>
+ * {@code isCanonical} checks exactly these rules and {@code doCanonicalize}
produces them; the
+ * two must be changed together when a rule changes or a type with
alternative encodings is
+ * added to the spec.
+ */
+ public static Variant canonicalize(Variant v) {
+ if (isCanonical(v)) {
+ return v;
+ }
+ return doCanonicalize(v);
+ }
+
+ /**
+ * Check whether {@code v} already satisfies every rule listed in {@link
#canonicalize}. Return
+ * false at the first non-canonical encoding. Malformed bytes also return
false so that the
Review Comment:
**Nit (P3):** Some malformed content throws from inside the check instead of
returning false. The DECIMAL branch calls `getDecimalWithOriginalScale` (line
376), whose `checkDecimal` throws `MALFORMED_VARIANT` for an out-of-range
precision or scale, even though the header is readable and the root `valueSize`
check passed. The suite's own malformed inputs `decimal("1", 39, DECIMAL16,
16)` and `decimal("1000000000", 0, DECIMAL4, 4)` do exactly that with the empty
metadata. The test only asserts that `canonicalize` fails, which it does either
way. A container whose offset list runs past the value also throws, from
`valueSize`. Behavior is fine today, because `canonicalize` is the only caller
and fails with the same error in the rebuild. But a caller that relies on this
Javadoc and uses `isCanonical` as a predicate would get an exception instead of
`false`. The Javadoc should say that malformed content the check has to decode,
such as decimals or container offset lists, can raise `MALF
ORMED_VARIANT`, and that an unknown type ID raises
`UNKNOWN_PRIMITIVE_TYPE_IN_VARIANT`.
See **Shared repair plan 1** in the review body.
##########
common/variant/src/test/scala/org/apache/spark/types/variant/VariantCanonicalizeSuite.scala:
##########
@@ -0,0 +1,860 @@
+/*
+ * 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.spark.types.variant
+
+import java.nio.charset.StandardCharsets
+
+import scala.util.Random
+
+import org.scalatest.funsuite.AnyFunSuite // scalastyle:ignore funsuite
+
+import org.apache.spark.SparkRuntimeException
+import org.apache.spark.types.variant.VariantUtil._
+
+class VariantCanonicalizeSuite extends AnyFunSuite { // scalastyle:ignore
funsuite
+ private val emptyMetadata = Array[Byte](VERSION, 0, 0)
+ private val nullValue = Array(primitiveHeader(NULL))
+ private val trueValue = Array(primitiveHeader(TRUE))
+ private val falseValue = Array(primitiveHeader(FALSE))
+
+ private def bytes(values: Int*): Array[Byte] = values.map(_.toByte).toArray
+
+ private def utf8(value: String): Array[Byte] =
value.getBytes(StandardCharsets.UTF_8)
+
+ private def littleEndian(value: BigInt, width: Int): Array[Byte] =
+ Array.tabulate(width)(index => ((value >> (8 * index)) & 0xff).toByte)
+
+ private def concatenate(parts: Seq[Array[Byte]]): Array[Byte] =
+ parts.iterator.flatMap(_.iterator).toArray
+
+ private def integer(value: Long, typeInfo: Int = INT1, width: Int = 1):
Array[Byte] =
+ Array(primitiveHeader(typeInfo)) ++ littleEndian(BigInt(value), width)
+
+ private def decimal(
+ unscaled: String,
+ scale: Int,
+ typeInfo: Int,
+ width: Int): Array[Byte] = {
+ Array(primitiveHeader(typeInfo), scale.toByte) ++
littleEndian(BigInt(unscaled), width)
+ }
+
+ private def floating(rawBits: Long, typeInfo: Int, width: Int): Array[Byte] =
+ Array(primitiveHeader(typeInfo)) ++ littleEndian(BigInt(rawBits), width)
+
+ private def string(value: Array[Byte], longEncoding: Boolean = false):
Array[Byte] = {
+ if (longEncoding) {
+ Array(primitiveHeader(LONG_STR)) ++ littleEndian(BigInt(value.length),
4) ++ value
+ } else {
+ Array(shortStrHeader(value.length)) ++ value
+ }
+ }
+
+ private def metadata(
+ keys: Seq[Array[Byte]],
+ offsetSize: Int = 1,
+ flags: Int = 0): Array[Byte] = {
+ val offsets = keys.scanLeft(0)((offset, key) => offset + key.length)
+ bytes(metadataHeader(offsetSize) | flags) ++
littleEndian(BigInt(keys.size), offsetSize) ++
+ concatenate(offsets.map(offset => littleEndian(BigInt(offset),
offsetSize))) ++
+ concatenate(keys)
+ }
+
+ private def dictionary(names: String*): Array[Byte] =
metadata(names.map(utf8))
+
+ private def array(
+ elements: Seq[Array[Byte]],
+ offsetSize: Int = 1,
+ largeSize: Boolean = false): Array[Byte] = {
+ val offsets = elements.scanLeft(0)((offset, element) => offset +
element.length)
+ Array(arrayHeader(largeSize, offsetSize)) ++
+ littleEndian(BigInt(elements.size), if (largeSize) 4 else 1) ++
+ concatenate(offsets.map(offset => littleEndian(BigInt(offset),
offsetSize))) ++
+ concatenate(elements)
+ }
+
+ private def obj(
+ fields: Seq[(Int, Array[Byte])],
+ idSize: Int = 1,
+ offsetSize: Int = 1,
+ largeSize: Boolean = false,
+ physicalOrder: Seq[Int] = Seq.empty): Array[Byte] = {
+ val order = if (physicalOrder.isEmpty) fields.indices else physicalOrder
+ val offsets = new Array[Int](fields.size)
+ var dataSize = 0
+ order.foreach { fieldIndex =>
+ offsets(fieldIndex) = dataSize
+ dataSize += fields(fieldIndex)._2.length
+ }
+ Array(objectHeader(largeSize, idSize, offsetSize)) ++
+ littleEndian(BigInt(fields.size), if (largeSize) 4 else 1) ++
+ concatenate(fields.map { case (keyId, _) => littleEndian(BigInt(keyId),
idSize) }) ++
+ concatenate((offsets.toSeq :+ dataSize).map(offset =>
+ littleEndian(BigInt(offset), offsetSize))) ++
+ concatenate(order.map(fieldIndex => fields(fieldIndex)._2))
+ }
+
+ private def assertBytes(actual: Array[Byte], expected: Array[Byte], label:
String): Unit = {
+ assert(actual.sameElements(expected),
+ s"$label: expected ${expected.length} bytes, got ${actual.length} bytes;
" +
+ s"expected prefix ${expected.take(32).mkString(",")}, " +
+ s"actual prefix ${actual.take(32).mkString(",")}")
+ }
+
+ // The input is canonical exactly when it already has the expected bytes.
+ private def check(
+ input: Variant,
+ expectedValue: Array[Byte],
+ expectedMetadata: Array[Byte] = emptyMetadata): Variant = {
+ val canonicalInput = input.pos == 0 &&
input.value.sameElements(expectedValue) &&
+ input.metadata.sameElements(expectedMetadata)
+ val originalValue = input.value.clone()
+ val originalMetadata = input.metadata.clone()
+ assert(VariantBuilder.isCanonical(input) == canonicalInput)
+ val actual = VariantBuilder.canonicalize(input)
+ assert((actual eq input) == canonicalInput)
+ assert(actual.pos == 0)
+ assertBytes(actual.value, expectedValue, "canonical value")
+ assertBytes(actual.metadata, expectedMetadata, "canonical metadata")
+ assert(VariantBuilder.isCanonical(actual))
+ assert(VariantBuilder.canonicalize(actual) eq actual)
+ Seq(input, actual).foreach { source =>
+ val rebuilt = VariantBuilder.doCanonicalize(source)
+ assertBytes(rebuilt.value, expectedValue, "unconditional rebuild value")
+ assertBytes(rebuilt.metadata, expectedMetadata, "unconditional rebuild
metadata")
+ }
+ assertBytes(input.value, originalValue, "unchanged input value")
+ assertBytes(input.metadata, originalMetadata, "unchanged input metadata")
+ actual
+ }
+
+ private def checkKeyless(inputValue: Array[Byte], expectedValue:
Array[Byte]): Unit =
+ check(new Variant(inputValue, emptyMetadata), expectedValue)
+
+ private def checkError(condition: String)(body: => Any): Unit = {
+ assert(intercept[SparkRuntimeException](body).getCondition == condition)
+ }
+
+ test("canonical encoding has pinned value and metadata bytes") {
+ checkKeyless(bytes(24, 1, 0, 0, 0, 0, 0, 0, 0), bytes(12, 1))
+ checkKeyless(bytes(64, 1, 0, 0, 0, 120), bytes(5, 120))
+ check(VariantBuilder.parseJson("{\"b\":2,\"a\":1}", false),
+ bytes(2, 2, 0, 1, 0, 2, 4, 12, 1, 12, 2), bytes(1, 2, 0, 1, 2, 97, 98))
+ checkKeyless(bytes(2, 0, 0), bytes(2, 0, 0))
+ checkKeyless(bytes(3, 0, 0), bytes(3, 0, 0))
+ }
+
+ test("metadata helpers encode and decode every offset-width header") {
+ Seq((1, 0x01), (2, 0x41), (3, 0x81), (4, 0xc1)).foreach {
+ case (width, header) =>
+ val input = bytes(header) ++ littleEndian(BigInt(2), width) ++
+ concatenate(Seq(0, 1, 2).map(offset => littleEndian(BigInt(offset),
width))) ++
+ bytes(97, 98)
+ assert(metadataHeader(width) == header.toByte)
+ assert(getMetadataOffsetSize(input) == width)
+ assert(getMetadataNumKeys(input, width) == 2)
+ assert(getMetadataStringStart(input, 2, width) == 1 + 4 * width)
+ assert(getMetadataKey(input, 0) == "a")
+ assert(getMetadataKey(input, 1) == "b")
+ }
+ }
+
+ test("isCanonical rejects a metadata last offset past the available string
bytes") {
+ val value = obj(Seq(0 -> integer(1)))
+ val validMetadata = dictionary("ab")
+ assert(VariantBuilder.isCanonical(new Variant(value, validMetadata)))
+
+ Seq(1, 2).foreach { missingBytes =>
+ val truncatedMetadata = validMetadata.dropRight(missingBytes)
+ assert(!VariantBuilder.isCanonical(new Variant(value,
truncatedMetadata)))
+ }
+ }
+
+ test("isCanonical rejects decreasing or out-of-bounds dictionary offsets") {
+ val value = obj(Seq(0 -> integer(1), 1 -> integer(2), 2 -> integer(3)))
+ assert(VariantBuilder.isCanonical(new Variant(value, dictionary("a", "b",
"c"))))
+
+ val decreasingOffsets = bytes(VERSION, 3, 0, 2, 1, 3) ++ utf8("abc")
+ val outOfBoundsOffset = bytes(VERSION, 3, 0, 4, 2, 3) ++ utf8("abc")
+ Seq(decreasingOffsets, outOfBoundsOffset).foreach { invalidMetadata =>
+ assert(!VariantBuilder.isCanonical(new Variant(value, invalidMetadata)))
+ }
+ }
+
+ test("getMetadataKey rejects a dictionary size whose string start overflows
an int") {
+ val forged = Array(metadataHeader(4)) ++ littleEndian(BigInt(Int.MaxValue
- 1), 4) ++
+ littleEndian(BigInt(0), 4) ++ littleEndian(BigInt(1), 4) ++ utf8("a")
+ checkError("MALFORMED_VARIANT") { getMetadataKey(forged, 0) }
+ }
+
+ test("dictionary permutation and object payload order canonicalize
independently") {
+ val expectedValue = obj(Seq(0 -> integer(1), 1 -> integer(2)))
+ Seq(Seq("a", "b"), Seq("b", "a")).foreach { keys =>
+ Seq(Seq(0, 1), Seq(1, 0)).foreach { order =>
+ val value = obj(Seq(keys.indexOf("a") -> integer(1), keys.indexOf("b")
-> integer(2)),
+ physicalOrder = order)
+ check(new Variant(value, dictionary(keys: _*)), expectedValue,
dictionary("a", "b"))
+ }
+ }
+ }
+
+ test("unused dictionary entries are removed and repeated names share an ID")
{
+ val sourceMetadata = dictionary("b", "unused", "a", "a")
+ val sourceValue = array(Seq(
+ obj(Seq(2 -> integer(1))), obj(Seq(3 -> integer(2))), obj(Seq(0 ->
integer(3)))))
+ val expectedValue = array(Seq(
+ obj(Seq(0 -> integer(1))), obj(Seq(0 -> integer(2))), obj(Seq(1 ->
integer(3)))))
+ check(new Variant(sourceValue, sourceMetadata), expectedValue,
dictionary("a", "b"))
+ check(new Variant(integer(1), sourceMetadata), integer(1))
+ check(new Variant(integer(1), metadata(Seq(bytes(0xff)))), integer(1))
+ check(new Variant(obj(Seq(0 -> integer(1))), bytes(1, 1, 1, 2, 120, 97)),
+ obj(Seq(0 -> integer(1))), dictionary("a"))
+ val emptyKey = obj(Seq(0 -> nullValue, 1 -> integer(1)))
+ check(new Variant(emptyKey, dictionary("", "a")), emptyKey, dictionary("",
"a"))
+ }
+
+ test("duplicate dictionary entries and repeated field IDs are never
canonical") {
+ check(new Variant(array(Seq(obj(Seq(0 -> integer(1))), obj(Seq(1 ->
integer(2))))),
+ dictionary("a", "a")),
+ array(Seq(obj(Seq(0 -> integer(1))), obj(Seq(0 -> integer(2))))),
dictionary("a"))
+ val repeatedId = new Variant(obj(Seq(0 -> integer(1), 0 -> integer(2))),
dictionary("a"))
+ assert(!VariantBuilder.isCanonical(repeatedId))
+ checkError("VARIANT_DUPLICATE_KEY") {
VariantBuilder.canonicalize(repeatedId) }
+ }
+
+ // scalastyle:off nonascii
+ test("dictionary and fields use unsigned UTF-8 rather than UTF-16 ordering")
{
+ val bmpKey = utf8("\ue000")
+ val supplementaryKey = utf8("\ud800\udc00")
+ val expectedMetadata = metadata(Seq(bmpKey, supplementaryKey))
+ val expectedValue = obj(Seq(0 -> integer(10), 1 -> integer(20)))
+ Seq(Seq(0 -> integer(20), 1 -> integer(10)),
+ Seq(1 -> integer(10), 0 -> integer(20))).foreach { fields =>
+ check(new Variant(obj(fields), metadata(Seq(supplementaryKey, bmpKey))),
+ expectedValue, expectedMetadata)
+ }
+ }
+
+ test("valid multi-byte UTF-8 keys are kept and sorted by their bytes") {
+ check(new Variant(obj(Seq(2 -> integer(1), 1 -> integer(2), 0 ->
integer(3))),
+ dictionary("\u4f60", "caf\u00e9", "cafe")),
+ obj(Seq(0 -> integer(1), 1 -> integer(2), 2 -> integer(3))),
+ dictionary("cafe", "caf\u00e9", "\u4f60"))
+ }
+ // scalastyle:on nonascii
+
+ test("all independent metadata ID offset and count widths converge") {
+ val fields = Seq(0 -> trueValue, 1 -> integer(258, INT2, 2), 2 ->
nullValue)
+ val elements = fields.map(_._2)
+ val keys = Seq("a", "b", "c").map(utf8)
+ for {
+ metadataWidth <- 1 to 4
+ idWidth <- 1 to 4
+ offsetWidth <- 1 to 4
+ largeSize <- Seq(false, true)
+ } {
+ withClue(s"metadata=$metadataWidth id=$idWidth offset=$offsetWidth
large=$largeSize: ") {
+ check(new Variant(obj(fields, idWidth, offsetWidth, largeSize),
+ metadata(keys, metadataWidth)), obj(fields), metadata(keys))
+ }
+ }
+ for {
+ metadataWidth <- 1 to 4
+ offsetWidth <- 1 to 4
+ largeSize <- Seq(false, true)
+ } {
+ val keylessMetadata = metadata(Seq.empty, metadataWidth)
+ check(new Variant(array(elements, offsetWidth, largeSize),
keylessMetadata),
+ array(elements))
+ check(new Variant(array(Seq.empty, offsetWidth, largeSize),
keylessMetadata),
+ array(Seq.empty))
+ check(new Variant(obj(Seq.empty, 4, offsetWidth, largeSize),
keylessMetadata),
+ obj(Seq.empty))
+ }
+ }
+
+ test("metadata flags and trailing bytes are not part of canonical output") {
+ Seq(1 << 4, 1 << 5, (1 << 4) | (1 << 5)).foreach { flags =>
+ check(new Variant(integer(1), metadata(Seq.empty, flags = flags)),
integer(1))
+ }
+ checkKeyless(integer(1) ++ bytes(0, 0), integer(1))
+ check(new Variant(integer(1), emptyMetadata ++ bytes(0, 0)), integer(1))
+ }
+
+ test("reserved container header bits are cleared during normalization") {
+ val objectValue = obj(Seq(0 -> integer(1)))
+ val objectWithReservedBit = objectValue.clone()
+ objectWithReservedBit(0) = (objectWithReservedBit(0) | 0x80).toByte
+ check(new Variant(objectWithReservedBit, dictionary("a")), objectValue,
dictionary("a"))
+ val arrayValue = array(Seq(integer(1)))
+ Seq(0x20, 0x40, 0x80, 0xe0).foreach { reservedBits =>
+ val arrayWithReservedBits = arrayValue.clone()
+ arrayWithReservedBits(0) = (arrayWithReservedBits(0) |
reservedBits).toByte
+ checkKeyless(arrayWithReservedBits, arrayValue)
+ }
+ }
+
+ test("padding and shared payloads inside containers become contiguous") {
+ val paddedArray = Array(arrayHeader(false, 1)) ++ bytes(1, 0, 3) ++
integer(1) ++ bytes(0)
+ checkKeyless(paddedArray, array(Seq(integer(1))))
+ val paddedObject =
+ Array(objectHeader(false, 1, 1)) ++ bytes(1, 0, 0, 3) ++ integer(1) ++
bytes(0)
+ check(new Variant(paddedObject, dictionary("a")), obj(Seq(0 ->
integer(1))), dictionary("a"))
+ val shared = Array(objectHeader(false, 1, 1)) ++ bytes(2, 0, 1, 0, 0, 2)
++ integer(7)
+ check(new Variant(shared, dictionary("a", "b")),
+ obj(Seq(0 -> integer(7), 1 -> integer(7))), dictionary("a", "b"))
+ }
+
+ test("container count and payload widths change at 255 and 256") {
+ Seq((255, false, 1, 1), (256, true, 1, 2), (257, true, 2, 2)).foreach {
+ case (count, largeSize, idWidth, offsetWidth) =>
+ val keys = (0 until count).map(index => utf8(f"key$index%03d"))
+ val fields = (0 until count).map(index => index -> nullValue)
+ val expectedMetadata = metadata(keys, 2)
+ val expectedObject = obj(fields, idWidth, offsetWidth, largeSize)
+ check(new Variant(obj(fields, 4, 4, largeSize = true), metadata(keys,
4)),
+ expectedObject, expectedMetadata)
+ check(new Variant(expectedObject, expectedMetadata), expectedObject,
expectedMetadata)
+ val elements = Seq.fill(count)(nullValue)
+ checkKeyless(array(elements, 4, largeSize = true), array(elements,
offsetWidth, largeSize))
+ }
+ }
+
+ test("object ID width follows the largest referenced global ID") {
+ val keys = (0 to 256).map(index => utf8(f"key$index%03d"))
+ val sourceElements = keys.indices.map(index => obj(Seq(index ->
nullValue), 4, 4, true))
+ val expectedElements = keys.indices.map { index =>
+ obj(Seq(index -> nullValue), idSize = if (index <= U8_MAX) 1 else 2)
+ }
+ val expectedValue = array(expectedElements, 2, largeSize = true)
+ check(new Variant(array(sourceElements, 4, largeSize = true),
metadata(keys, 4)),
+ expectedValue, metadata(keys, 2))
+ check(new Variant(obj(Seq(256 -> integer(1)), 2), metadata(keys, 2)),
+ obj(Seq(0 -> integer(1))), metadata(Seq(keys.last)))
+ }
+
+ test("metadata and container offsets have real one two and three byte
boundaries") {
+ Seq((255, 1), (256, 2), (65535, 2), (65536, 3)).foreach {
+ case (size, expectedWidth) =>
+ withClue(s"size=$size width=$expectedWidth: ") {
+ val key = Array.fill[Byte](size)('a'.toByte)
+ val objectValue = obj(Seq(0 -> nullValue))
+ val expectedMetadata = metadata(Seq(key), expectedWidth)
+ check(new Variant(objectValue, metadata(Seq(key), 4)), objectValue,
expectedMetadata)
+ check(new Variant(objectValue, expectedMetadata), objectValue,
expectedMetadata)
+ val payload = Array(primitiveHeader(BINARY)) ++
littleEndian(BigInt(size - 5), 4) ++
+ Array.fill[Byte](size - 5)(7)
+ checkKeyless(array(Seq(payload), 4), array(Seq(payload),
expectedWidth))
+ val expectedObject = obj(Seq(0 -> payload), offsetSize =
expectedWidth)
+ check(new Variant(obj(Seq(0 -> payload), offsetSize = 4),
dictionary("a")),
+ expectedObject, dictionary("a"))
+ }
+ }
+ }
+
+ test("signed integer boundaries choose the smallest encoding") {
+ val boundaries = Seq(
+ (Long.MinValue, INT8, 8), (Int.MinValue.toLong - 1, INT8, 8),
+ (Int.MinValue.toLong, INT4, 4), (Short.MinValue.toLong - 1, INT4, 4),
+ (Short.MinValue.toLong, INT2, 2), (Byte.MinValue.toLong - 1, INT2, 2),
+ (Byte.MinValue.toLong, INT1, 1), (-1L, INT1, 1), (0L, INT1, 1), (1L,
INT1, 1),
+ (Byte.MaxValue.toLong, INT1, 1), (Byte.MaxValue.toLong + 1, INT2, 2),
+ (Short.MaxValue.toLong, INT2, 2), (Short.MaxValue.toLong + 1, INT4, 4),
+ (Int.MaxValue.toLong, INT4, 4), (Int.MaxValue.toLong + 1, INT8, 8),
+ (Long.MaxValue, INT8, 8))
+ boundaries.foreach { case (number, expectedType, expectedWidth) =>
+ Seq((INT1, 1), (INT2, 2), (INT4, 4), (INT8, 8)).foreach { case
(typeInfo, width) =>
+ if (width >= expectedWidth) {
+ withClue(s"integer=$number width=$width: ") {
+ checkKeyless(integer(number, typeInfo, width),
+ integer(number, expectedType, expectedWidth))
+ }
+ }
+ }
+ }
+ }
+
+ test("decimal normalization respects precision scale and signed long
promotion limits") {
+ val cases = Seq(
Review Comment:
**Nit (P3):** Every promotable case here carries trailing zeros: `("0",
30)`, `("100", 2)`, `("100000", 2)` and the long-boundary values at scale 1.
The later `original.scale() == canonical.scale()` check in `isValueCanonical`
would reject those even without `decimalPromotesToLong`. The only scale-0 case,
1E20, doesn't fit a long. The fuzz inputs come from `parseJson`, which never
emits a long-fitting scale-0 decimal, and `randomizeBinaryEncoding` copies
decimals unchanged. So the promotion check in `isCanonical`
(`VariantBuilder.java:378-380`), the only rule that rejects a value like
DECIMAL4 scale 0 unscaled 5, has no failure signal. If it were removed or
narrowed, `isCanonical` would accept that value as canonical, although `INT1 5`
is the canonical form. Equal values would then keep different bytes, and every
test would still pass. Spark writes exactly this shape when casting
`DECIMAL(p,0)` values to variant, because `castToVariant` passes each decimal
to `appendDecimal` with
its own scale. A scale-0 case such as `decimal("5", 0, DECIMAL4, 4)` expecting
`integer(5)`, plus negative and long-boundary variants, would let `check()`
catch it.
See **Shared repair plan 2** in the review body.
##########
common/variant/src/main/java/org/apache/spark/types/variant/VariantBuilder.java:
##########
@@ -555,18 +806,163 @@ private void appendVariantImpl(byte[] value, byte[]
metadata, int pos) {
int offset = readUnsigned(value, offsetStart + offsetSize * i,
offsetSize);
int elementPos = dataStart + offset;
offsets.add(writePos - start);
- appendVariantImpl(value, metadata, elementPos);
+ appendVariantImpl(value, metadata, elementPos, canonicalIds);
}
finishWritingArray(start, offsets);
return null;
});
break;
+ default:
+ if (canonicalIds != null) {
+ appendCanonicalizedScalar(value, pos);
+ } else {
+ shallowAppendVariantImpl(value, pos);
+ }
+ break;
+ }
+ }
+
+ // Append the scalar at `value[pos...]` in canonical form. Keep in sync with
the scalar cases
+ // of `isValueCanonical`.
+ private void appendCanonicalizedScalar(byte[] value, int pos) {
+ switch (getType(value, pos)) {
+ case LONG:
+ appendLong(getLong(value, pos));
+ break;
+ case DECIMAL: {
+ BigDecimal stripped = getDecimal(value, pos);
+ if (decimalPromotesToLong(stripped)) {
+ appendLong(stripped.longValue());
+ } else {
+ appendDecimal(canonicalDecimalForm(stripped));
+ }
+ break;
+ }
+ case FLOAT:
+ appendFloat(normalizeNegativeZero(getFloat(value, pos)));
+ break;
+ case DOUBLE:
+ appendDouble(normalizeNegativeZero(getDouble(value, pos)));
+ break;
+ case STRING: {
+ int size = valueSize(value, pos);
+ if (size <= 0 || (long) pos + size > value.length) {
+ throw malformedVariant();
+ }
+ int headerSize = (value[pos] & BASIC_TYPE_MASK) == SHORT_STR ? 1 : 1 +
U32_SIZE;
+ appendString(value, pos + headerSize, size - headerSize);
+ break;
+ }
default:
shallowAppendVariantImpl(value, pos);
break;
}
}
+ private void buildCanonicalized(byte[] value, byte[] metadata, int pos) {
+ int offsetSize = getMetadataOffsetSize(metadata);
+ int numKeys = getMetadataNumKeys(metadata, offsetSize);
+ // Validate metadata table bounds before allocating the key-ID mapping.
+ getMetadataStringStart(metadata, numKeys, offsetSize);
+ int[] canonicalIds = new int[numKeys];
+ Arrays.fill(canonicalIds, -1);
+ TreeMap<byte[], Integer> keys = new TreeMap<>(VariantUtil::compareKeys);
+ collectAllObjectKeys(value, metadata, pos, canonicalIds, keys);
+ // Remap the IDs assigned during collection to unsigned byte order without
decoding keys.
+ int[] sortedIds = new int[keys.size()];
+ for (java.util.Map.Entry<byte[], Integer> key : keys.entrySet()) {
+ sortedIds[key.getValue()] = dictionaryKeys.size();
+ dictionaryKeys.add(key.getKey());
+ }
+ for (int i = 0; i < canonicalIds.length; ++i) {
+ if (canonicalIds[i] >= 0) {
+ canonicalIds[i] = sortedIds[canonicalIds[i]];
+ }
+ }
+ appendVariantImpl(value, metadata, pos, canonicalIds);
+ }
+
+ private static void collectAllObjectKeys(
+ byte[] value, byte[] metadata, int pos, int[] canonicalIds,
TreeMap<byte[], Integer> keys) {
+ checkIndex(pos, value.length);
+ switch (value[pos] & BASIC_TYPE_MASK) {
+ case OBJECT:
+ handleObject(value, pos, (size, idSize, offsetSize, idStart,
offsetStart, dataStart) -> {
+ for (int fieldIndex = 0; fieldIndex < size; ++fieldIndex) {
+ int keyId = readUnsigned(value, idStart + idSize * fieldIndex,
idSize);
+ int offset = readUnsigned(value, offsetStart + offsetSize *
fieldIndex, offsetSize);
+ if (keyId >= canonicalIds.length) {
+ throw malformedVariant();
+ }
+ if (canonicalIds[keyId] < 0) {
+ byte[] key = getMetadataKeyBytes(metadata, keyId);
+ Integer id = keys.get(key);
+ if (id == null) {
+ id = keys.size();
+ keys.put(key, id);
+ }
+ canonicalIds[keyId] = id;
+ }
+ collectAllObjectKeys(value, metadata, dataStart + offset,
canonicalIds, keys);
+ }
+ return null;
+ });
+ break;
+ case ARRAY:
+ handleArray(value, pos, (size, offsetSize, offsetStart, dataStart) -> {
+ for (int elementIndex = 0; elementIndex < size; ++elementIndex) {
+ int offset = readUnsigned(value, offsetStart + offsetSize *
elementIndex, offsetSize);
+ collectAllObjectKeys(value, metadata, dataStart + offset,
canonicalIds, keys);
Review Comment:
**Non-blocking (P2):** This recursion follows every element reference once
per path that reaches it and never writes, so `checkCapacity` doesn't bound it.
The size limit only applies once the rebuild in `appendVariantImpl` starts
writing. The change accepts container offsets that alias one child and expands
them (see the 'padding and shared payloads inside containers become contiguous'
test), and the `Variant` constructor checks only the version and the size. So a
value of a few hundred bytes made of 60 nested two-element arrays, each with
offsets `[0, 0, childSize]`, gets through. `isCanonical` gives up after one
path, and this pre-pass then makes about 2^61 calls before anything is written.
Instead of failing with the size-limit error, `canonicalize` spins effectively
forever. The variant code has no interruption check, so cancelling the task
doesn't stop it. Only direct callers can reach this today. Once the expression
is injected before GROUP BY/DISTINCT, though, it would run
on every stored row, including variant bytes written by other tools.
One way to bound it at no cost to ordinary values: count the values this
walk visits, and throw `VariantSizeLimitException` once the count exceeds
`SIZE_LIMIT`. The rebuild writes every visited value as at least one byte, so a
value past that count would overflow the write pass anyway. A value that never
references the same position twice can't reach the count.
**Verification:**
- **Regression:** Canonicalizing a value of several dozen nested arrays
whose elements all alias one child fails quickly with the size-limit error
under the test size limit, instead of hanging while keys are collected.
- **Behavior:** The existing shared-payload, padding, permutation and fuzz
canonicalization cases produce the same canonical bytes as before.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]