This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new aca0501f2932 [SPARK-58731][SQL] Exempt empty sketches from the 
hll_union lgConfigK check
aca0501f2932 is described below

commit aca0501f2932c1ad634044e81d9abe6eab89ea2c
Author: Joel Robin P <[email protected]>
AuthorDate: Thu Aug 13 00:37:59 2026 +0800

    [SPARK-58731][SQL] Exempt empty sketches from the hll_union lgConfigK check
    
    ### What changes were proposed in this pull request?
    
    `hll_union_agg` has no `lgConfigK` parameter, so it infers precision from 
the sketches it reads. For a group whose sketches are all NULL it never sees 
one, and `eval` falls back to a `Union` at the DataSketches default 
(`lgConfigK=12`). That empty sketch is observable -- it gets stored -- and 
could not afterwards be merged with sketches of any other precision, so the 
function produced output it could not itself consume.
    
    This PR makes empty sketches exempt from the precision check, on the 
grounds that an empty sketch holds no coupons and therefore carries no 
precision to reconcile:
    
    - `HllUnionAgg` gains a `mergeSketch` helper used by both `update` and 
`merge`. It skips `compareLgConfigK` unless both sides are non-empty, and 
re-seeds an empty `Union` at the first non-empty sketch's `lgConfigK`. Routing 
both paths through one helper matters because the failure reproduced through 
`merge` as well, so fixing only `update` would leave the partial-to-final 
aggregation path broken.
    - Scalar `HllUnion` gets the same exemption, and picks the non-empty side's 
`lgConfigK` instead of an unconditional `Math.min` -- otherwise an empty sketch 
at 12 would silently downsample a populated sketch at 15.
    
    Merging an empty sketch is now a no-op with respect to precision, which 
also makes the result independent of the order rows reach the aggregate.
    
    ### Why are the changes needed?
    
    Rolling up stored sketches fails under default settings when any group had 
only NULL input:
    
    ```
    [HLL_UNION_DIFFERENT_LG_K] Sketches have different `lgConfigK` values: 12 
and 15.
    Set the `allowDifferentLgConfigK` parameter to true to enable unions of 
different lgConfigK values.
    ```
    
    The failure is self-referential: `hll_union_agg` emits a sketch that 
`hll_union_agg` then refuses. Reproduction:
    
    ```sql
    WITH sketches AS (
      SELECT 'has_data' AS grp, hll_sketch_agg(CAST(id AS STRING), 15) AS sketch
      FROM (SELECT explode(sequence(1, 1000)) AS id)
      UNION ALL
      SELECT 'all_null' AS grp, CAST(NULL AS BINARY) AS sketch
    ),
    stored AS (SELECT grp, hll_union_agg(sketch) AS sketch FROM sketches GROUP 
BY grp)
    SELECT hll_sketch_estimate(hll_union_agg(sketch)) FROM stored;
    ```
    
    The workaround -- setting `allowDifferentLgConfigK = true` -- is a poor 
fit, since it also permits genuine precision loss between two populated 
sketches, which is the case the check exists to catch. An all-NULL group is 
common in practice (a partition with no matching rows), so a user can hit this 
without ever having mixed precisions deliberately.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, a bug fix. Queries that previously failed with 
`HLL_UNION_DIFFERENT_LG_K` when unioning an empty sketch with a 
differing-precision sketch now succeed, and the populated sketch's `lgConfigK` 
is preserved.
    
    No change for previously-succeeding queries: sketches of equal precision 
are unaffected, and two **non-empty** sketches with differing `lgConfigK` still 
raise `HLL_UNION_DIFFERENT_LG_K` unless `allowDifferentLgConfigK` is set. One 
narrow behavior change worth noting for review: `hll_union(empty12, 
populated15)` previously downsampled to 12 when `allowDifferentLgConfigK = 
true`; it now returns 15, since the empty side has no precision to impose.
    
    ### How was this patch tested?
    
    New tests, all passing locally:
    
    - `DatasketchesHllSketchSuite` -- 4 tests covering the aggregate and scalar 
`hll_union`, in both merge orders; plus assertions that mismatched 
**non-empty** sketches still throw `HLL_UNION_DIFFERENT_LG_K`, and that 
`allowDifferentLgConfigK = true` still downsamples.
    - `DataFrameAggregateSuite` -- 2 end-to-end SQL tests reproducing the JIRA 
scenario, run at 1 and 2 partitions so both `update` and the partial-to-final 
`merge` path are exercised.
    
    Existing `hll_sketch_agg` / `hll_union_agg` tests and the `hll.sql` golden 
file pass unmodified. The full `sql/catalyst` suite also passes (10531 tests, 
380 suites, 0 failures).
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code
    
    Closes #57958 from joelrobin18/SPARK-58731-hll-union-empty-sketch-lgk.
    
    Authored-by: Joel Robin P <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../aggregate/datasketchesAggregates.scala         |  35 ++++--
 .../expressions/datasketchesExpressions.scala      |  12 +-
 .../aggregate/DatasketchesHllSketchSuite.scala     | 130 ++++++++++++++++++++-
 .../apache/spark/sql/DataFrameAggregateSuite.scala |  64 ++++++++++
 4 files changed, 229 insertions(+), 12 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/datasketchesAggregates.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/datasketchesAggregates.scala
index 4470c415f5c9..0a06c54409c0 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/datasketchesAggregates.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/datasketchesAggregates.scala
@@ -335,6 +335,30 @@ case class HllUnionAgg(
     }
   }
 
+  /**
+   * Merges `sketch` into the Union acting as the aggregation buffer, 
instantiating it if absent.
+   *
+   * An empty sketch holds no coupons, so it carries no precision: it is 
exempt from the lgConfigK
+   * check, and an empty Union is re-seeded at the lgConfigK of the first 
non-empty sketch. This
+   * keeps the empty sketch that `eval` emits for an all-NULL group mergeable, 
and makes the
+   * result independent of the order in which rows reach the aggregate.
+   *
+   * @param unionOption A previously initialized Union instance, or None
+   * @param sketch The sketch to merge in
+   */
+  private def mergeSketch(unionOption: Option[Union], sketch: HllSketch): 
Option[Union] = {
+    val union = unionOption match {
+      case Some(buffer) if buffer.isEmpty && !sketch.isEmpty => new 
Union(sketch.getLgConfigK)
+      case Some(buffer) => buffer
+      case None => new Union(sketch.getLgConfigK)
+    }
+    if (!union.isEmpty && !sketch.isEmpty) {
+      compareLgConfigK(union.getLgConfigK, sketch.getLgConfigK)
+    }
+    union.update(sketch)
+    Some(union)
+  }
+
   /**
    * Update the Union instance with the HllSketch byte array obtained from the 
row.
    *
@@ -348,10 +372,7 @@ case class HllUnionAgg(
         case BinaryType =>
           try {
             val sketch = 
HllSketch.wrap(Memory.wrap(v.asInstanceOf[Array[Byte]]))
-            val union = unionOption.getOrElse(new Union(sketch.getLgConfigK))
-            compareLgConfigK(union.getLgConfigK, sketch.getLgConfigK)
-            union.update(sketch)
-            Some(union)
+            mergeSketch(unionOption, sketch)
           } catch {
             case _: SketchesArgumentException | _: java.lang.Error
                  | _: ArrayIndexOutOfBoundsException =>
@@ -373,10 +394,8 @@ case class HllUnionAgg(
    */
   override def merge(unionOption: Option[Union], inputOption: Option[Union]): 
Option[Union] = {
     (unionOption, inputOption) match {
-      case (Some(union), Some(input)) =>
-        compareLgConfigK(union.getLgConfigK, input.getLgConfigK)
-        union.update(input.getResult(targetType))
-        Some(union)
+      case (Some(_), Some(input)) =>
+        mergeSketch(unionOption, input.getResult(targetType))
       // unclear if these scenarios can ever occur
       case (Some(_), None) =>
         unionOption
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datasketchesExpressions.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datasketchesExpressions.scala
index 743dd27c9240..46f9e494d2a8 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datasketchesExpressions.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/datasketchesExpressions.scala
@@ -135,11 +135,19 @@ case class HllUnion(first: Expression, second: 
Expression, third: Expression)
         throw QueryExecutionErrors.hllInvalidInputSketchBuffer(prettyName)
     }
     val allowDifferentLgConfigK = value3.asInstanceOf[Boolean]
-    if (!allowDifferentLgConfigK && sketch1.getLgConfigK != 
sketch2.getLgConfigK) {
+    if (!allowDifferentLgConfigK && !sketch1.isEmpty && !sketch2.isEmpty &&
+        sketch1.getLgConfigK != sketch2.getLgConfigK) {
       throw QueryExecutionErrors.hllUnionDifferentLgK(
         sketch1.getLgConfigK, sketch2.getLgConfigK, function = prettyName)
     }
-    val union = new Union(Math.min(sketch1.getLgConfigK, sketch2.getLgConfigK))
+    // An empty sketch holds no coupons, so it carries no precision: it is 
exempt from the
+    // lgConfigK check and does not drag the result down to its own lgConfigK.
+    val lgConfigK = (sketch1.isEmpty, sketch2.isEmpty) match {
+      case (true, false) => sketch2.getLgConfigK
+      case (false, true) => sketch1.getLgConfigK
+      case _ => Math.min(sketch1.getLgConfigK, sketch2.getLgConfigK)
+    }
+    val union = new Union(lgConfigK)
     union.update(sketch1)
     union.update(sketch2)
     union.getResult(targetType).toUpdatableByteArray
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/aggregate/DatasketchesHllSketchSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/aggregate/DatasketchesHllSketchSuite.scala
index 0f7f5ca54be0..4dcc3bf28d24 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/aggregate/DatasketchesHllSketchSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/aggregate/DatasketchesHllSketchSuite.scala
@@ -23,9 +23,9 @@ import scala.util.Random
 import org.apache.datasketches.hll.HllSketch
 import org.apache.datasketches.memory.Memory
 
-import org.apache.spark.SparkFunSuite
+import org.apache.spark.{SparkFunSuite, SparkRuntimeException}
 import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{BoundReference, 
HllSketchEstimate}
+import org.apache.spark.sql.catalyst.expressions.{BoundReference, 
HllSketchEstimate, HllUnion, Literal}
 import org.apache.spark.sql.types.{BinaryType, DataType, IntegerType, 
LongType, StringType}
 import org.apache.spark.unsafe.types.UTF8String
 
@@ -153,4 +153,130 @@ class DatasketchesHllSketchSuite extends SparkFunSuite {
         s"but got: ${exception.getClass.getName}: ${exception.getMessage}"
     )
   }
+  /** Runs HllUnionAgg over `inputs` (a NULL entry stands for a NULL sketch) 
for a single group. */
+  private def unionAgg(inputs: Seq[Any], allowDifferentLgConfigK: Boolean): 
Array[Byte] = {
+    val aggFunc = new HllUnionAgg(
+      BoundReference(0, BinaryType, nullable = true), allowDifferentLgConfigK)
+    val buffer = inputs.foldLeft(aggFunc.createAggregationBuffer()) { (buf, 
input) =>
+      aggFunc.update(buf, InternalRow(input))
+    }
+    aggFunc.eval(buffer).asInstanceOf[Array[Byte]]
+  }
+
+  /** Runs HllSketchAgg at `lgConfigK` over `values` (a NULL entry stands for 
a NULL value). */
+  private def sketchAgg(values: Seq[Any], lgConfigK: Int): Array[Byte] = {
+    val aggFunc = new HllSketchAgg(BoundReference(0, StringType, nullable = 
true), lgConfigK)
+    val buffer = values.foldLeft(aggFunc.createAggregationBuffer()) { (buf, 
value) =>
+      aggFunc.update(buf, InternalRow(value))
+    }
+    aggFunc.eval(buffer).asInstanceOf[Array[Byte]]
+  }
+
+  /** Evaluates the scalar hll_union over two serialized sketches. */
+  private def scalarUnion(
+      left: Array[Byte], right: Array[Byte], allowDifferentLgConfigK: 
Boolean): Array[Byte] =
+    HllUnion(
+      Literal(left, BinaryType),
+      Literal(right, BinaryType),
+      
Literal(allowDifferentLgConfigK)).eval(InternalRow.empty).asInstanceOf[Array[Byte]]
+
+  private def lgConfigKOf(sketch: Array[Byte]): Int =
+    HllSketch.heapify(Memory.wrap(sketch)).getLgConfigK
+
+  private def estimateOf(sketch: Array[Byte]): Long =
+    HllSketchEstimate(BoundReference(0, BinaryType, nullable = true))
+      .eval(InternalRow(sketch)).asInstanceOf[Long]
+
+  private def stringValues(n: Int): Seq[Any] =
+    Seq.tabulate(n)(i => UTF8String.fromString(i.toString))
+
+  test("hll_union_agg on a group with no non-NULL sketch yields an empty 
default-lgConfigK " +
+    "sketch") {
+    // The aggregate has no lgConfigK parameter and never saw a sketch, so it 
has no precision to
+    // report and falls back to the Datasketches default. Documented here 
because the resulting
+    // sketch is observable, and must stay harmless to later unions (see the 
tests below).
+    val allNull = unionAgg(Seq(null, null), allowDifferentLgConfigK = false)
+    assert(estimateOf(allNull) == 0L)
+    assert(lgConfigKOf(allNull) == HllSketch.DEFAULT_LG_K)
+
+    // hll_sketch_agg does not share the problem only because it has the 
parameter: it builds its
+    // buffer eagerly at the requested lgConfigK. Without the argument it 
defaults to 12 as well.
+    val emptyAt15 = sketchAgg(Seq(null, null), 15)
+    assert(estimateOf(emptyAt15) == 0L)
+    assert(lgConfigKOf(emptyAt15) == 15)
+  }
+
+  test("hll_union_agg merges an empty sketch of a different lgConfigK without 
an error") {
+    // An empty sketch holds no coupons, so unioning it cannot lose 
information at any lgConfigK.
+    // This is the shape produced by the test above, i.e. what a persisted 
table ends up holding
+    // for a group whose sketches were all NULL.
+    val emptyAtDefaultLgK = unionAgg(Seq(null), allowDifferentLgConfigK = 
false)
+    val sketchAt15 = sketchAgg(stringValues(1000), 15)
+
+    Seq(true, false).foreach { allowDifferentLgConfigK =>
+      Seq(
+        ("empty sketch first", Seq[Any](emptyAtDefaultLgK, sketchAt15)),
+        ("empty sketch last", Seq[Any](sketchAt15, emptyAtDefaultLgK))
+      ).foreach { case (order, inputs) =>
+        val merged = unionAgg(inputs, allowDifferentLgConfigK)
+        // The non-empty sketch decides the precision, whichever order the 
rows arrive in: the
+        // result must not depend on which row the aggregate happens to see 
first.
+        assert(lgConfigKOf(merged) == 15,
+          s"$order (allowDifferentLgConfigK=$allowDifferentLgConfigK) changed 
the lgConfigK")
+        assert(estimateOf(merged) == estimateOf(sketchAt15),
+          s"$order (allowDifferentLgConfigK=$allowDifferentLgConfigK) changed 
the estimate")
+      }
+    }
+  }
+
+  test("hll_union_agg still rejects non-empty sketches with different 
lgConfigK") {
+    val sketchAt12 = sketchAgg(stringValues(1000), 12)
+    val sketchAt15 = sketchAgg(stringValues(1000), 15)
+
+    Seq(
+      Seq[Any](sketchAt12, sketchAt15),
+      Seq[Any](sketchAt15, sketchAt12)
+    ).foreach { inputs =>
+      val exception = intercept[SparkRuntimeException] {
+        unionAgg(inputs, allowDifferentLgConfigK = false)
+      }
+      assert(exception.getCondition == "HLL_UNION_DIFFERENT_LG_K")
+    }
+
+    // And still downsamples rather than erroring when the caller opts in.
+    assert(lgConfigKOf(unionAgg(Seq(sketchAt15, sketchAt12), 
allowDifferentLgConfigK = true)) == 12)
+  }
+
+  test("hll_union merges an empty sketch of a different lgConfigK without an 
error") {
+    val emptyAtDefaultLgK = unionAgg(Seq(null), allowDifferentLgConfigK = 
false)
+    val sketchAt15 = sketchAgg(stringValues(1000), 15)
+
+    Seq(true, false).foreach { allowDifferentLgConfigK =>
+      Seq(
+        ("empty sketch first", emptyAtDefaultLgK, sketchAt15),
+        ("empty sketch last", sketchAt15, emptyAtDefaultLgK)
+      ).foreach { case (order, left, right) =>
+        val merged = scalarUnion(left, right, allowDifferentLgConfigK)
+        assert(lgConfigKOf(merged) == 15,
+          s"$order (allowDifferentLgConfigK=$allowDifferentLgConfigK) changed 
the lgConfigK")
+        assert(estimateOf(merged) == estimateOf(sketchAt15),
+          s"$order (allowDifferentLgConfigK=$allowDifferentLgConfigK) changed 
the estimate")
+      }
+    }
+  }
+
+  test("hll_union still rejects non-empty sketches with different lgConfigK") {
+    val sketchAt12 = sketchAgg(stringValues(1000), 12)
+    val sketchAt15 = sketchAgg(stringValues(1000), 15)
+
+    Seq((sketchAt12, sketchAt15), (sketchAt15, sketchAt12)).foreach { case 
(left, right) =>
+      val exception = intercept[SparkRuntimeException] {
+        scalarUnion(left, right, allowDifferentLgConfigK = false)
+      }
+      assert(exception.getCondition == "HLL_UNION_DIFFERENT_LG_K")
+    }
+
+    // Two populated sketches still downsample to the smaller lgConfigK when 
opted in.
+    assert(lgConfigKOf(scalarUnion(sketchAt15, sketchAt12, 
allowDifferentLgConfigK = true)) == 12)
+  }
 }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameAggregateSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameAggregateSuite.scala
index 3bc766860372..1918acab6c4e 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameAggregateSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameAggregateSuite.scala
@@ -2807,6 +2807,70 @@ class DataFrameAggregateSuite extends SharedSparkSession
     }
   }
 
+  // Byte 3 of the serialized sketch holds lgConfigK, so SUBSTRING(hex(...), 
7, 2) reads it as a
+  // hex pair: 0x0F = 15, 0x0C = 12.
+  private val allNullGroupSketches =
+    """
+      |with sketches as (
+      |  select 'has_data' as grp, hll_sketch_agg(cast(id as string), 15) as 
sketch
+      |  from (select explode(sequence(1, 1000)) as id)
+      |  union all
+      |  select 'all_null' as grp, cast(null as binary) as sketch
+      |)
+      |""".stripMargin
+
+  test("hll_union_agg reports the default lgConfigK for a group with no 
non-NULL sketch") {
+    // hll_union_agg has no lgConfigK parameter, and the all-NULL group holds 
no sketch to take one
+    // from, so it cannot report the 15 the other group was built at. The 
estimate is correct either
+    // way; the mismatched precision is what the next test has to cope with.
+    checkAnswer(
+      sql(allNullGroupSketches +
+        """
+          |select
+          |  grp,
+          |  substring(hex(hll_union_agg(sketch)), 7, 2) as lg_config_k,
+          |  hll_sketch_estimate(hll_union_agg(sketch)) as estimate
+          |from sketches
+          |group by grp
+          |""".stripMargin),
+      Seq(Row("all_null", "0C", 0L), Row("has_data", "0F", 1000L)))
+  }
+
+  test("hll_union_agg and hll_union re-merge the empty sketch produced for an 
all-NULL group") {
+    // The stored sketches are now an empty lgConfigK=12 one beside a 
populated lgConfigK=15 one.
+    // Rolling them back up must not fail under default settings, because an 
empty sketch holds no
+    // coupons and so cannot cost precision at any lgConfigK.
+    val stored = sql(allNullGroupSketches +
+      "select grp, hll_union_agg(sketch) as sketch from sketches group by grp")
+      .collect()
+      .map(row => row.getString(0) -> row.getAs[Array[Byte]]("sketch"))
+      .toMap
+
+    Seq(
+      "empty sketch first" -> Seq(stored("all_null"), stored("has_data")),
+      "empty sketch last" -> Seq(stored("has_data"), stored("all_null"))
+    ).foreach { case (order, sketches) =>
+      // One partition exercises update(); two exercise the partial-to-final 
merge() as well.
+      Seq(1, 2).foreach { numPartitions =>
+        withClue(s"hll_union_agg, $order, $numPartitions partition(s): ") {
+          checkAnswer(
+            sketches.toDF("sketch").repartition(numPartitions).selectExpr(
+              "substring(hex(hll_union_agg(sketch)), 7, 2) as lg_config_k",
+              "hll_sketch_estimate(hll_union_agg(sketch)) as estimate"),
+            Seq(Row("0F", 1000L)))
+        }
+      }
+
+      withClue(s"hll_union, $order: ") {
+        checkAnswer(
+          Seq((sketches.head, sketches.last)).toDF("left", "right").selectExpr(
+            "substring(hex(hll_union(left, right)), 7, 2) as lg_config_k",
+            "hll_sketch_estimate(hll_union(left, right)) as estimate"),
+          Seq(Row("0F", 1000L)))
+      }
+    }
+  }
+
   test("hll_sketch_agg") {
     val df = Seq(1, 1, 2, 2, 3).toDF("col")
     checkAnswer(


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

Reply via email to