dtenedor commented on code in PR #59252:
URL: https://github.com/apache/spark/pull/59252#discussion_r4200070126
##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameAggregateSuite.scala:
##########
@@ -5031,6 +5031,62 @@ class DataFrameAggregateSuite extends SharedSparkSession
assert(estimateResult == 2.0)
}
+ private val intersectionSketchFunctions = Seq(
Review Comment:
Two more cases that I believe hit the same exception before this change, and
that are probably the most common triggers in practice:
- A global aggregate where an input partition has no rows. For a global
aggregate, an empty partition still emits a default partial buffer
(`ObjectAggregationIterator.outputForEmptyGroupingKeyWithoutInput`), and that
buffer is serialized. So a selective `WHERE` filter, or a table with more
partitions than rows, fails even when no sketch is NULL. For example,
`spark.sparkContext.parallelize(Seq(Row(sketch)), 2)` leaves the first
partition empty and should return the sketch's estimate. Zero input rows (`...
WHERE false`) should return an empty sketch.
- The intersection aggregate together with two or more DISTINCT aggregates,
e.g. `SELECT theta_intersection_agg(sketch), count(DISTINCT a), count(DISTINCT
b) FROM t`. `RewriteDistinctAggregates` replaces the regular aggregates' inputs
with NULL on the expanded rows of each DISTINCT group, so the intersection sees
all-NULL groups even when no input sketch is NULL.
Optional: a window aggregate whose frame contains only NULL sketches (this
calls `eval` without serializing), and running the grouped test with
`spark.sql.objectHashAggregate.sortBased.fallbackThreshold=1` and with
`spark.sql.execution.useObjectHashAggregateExec=false`, since the sort-based
fallback and `SortAggregateExec` also serialize partial buffers.
##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameAggregateSuite.scala:
##########
@@ -5031,6 +5031,62 @@ class DataFrameAggregateSuite extends SharedSparkSession
assert(estimateResult == 2.0)
}
+ private val intersectionSketchFunctions = Seq(
+ ("theta_sketch_agg(id)", "theta_intersection_agg",
"theta_sketch_estimate"),
+ ("tuple_sketch_agg_double(id, 1.0D)",
+ "tuple_intersection_agg_double", "tuple_sketch_estimate_double"),
+ ("tuple_sketch_agg_integer(id, 1)",
+ "tuple_intersection_agg_integer", "tuple_sketch_estimate_integer")
+ )
+
+ gridTest(
+ "intersection returns an empty sketch for all-null input")(
+ intersectionSketchFunctions) {
+ case (_, intersection, estimate) =>
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
Review Comment:
Nit: AQE and the shuffle partition count don't affect the partial
aggregation, whose partitions come from the input RDD, so these two confs can
be dropped (or add a comment saying why they're needed). Same in the next test.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/thetasketchesAggregates.scala:
##########
@@ -44,8 +44,22 @@ case class UnionAggregationBuffer(union: Union) extends
ThetaSketchState {
override def eval(): Array[Byte] = union.getResult.toByteArrayCompressed
}
case class IntersectionAggregationBuffer(intersection: Intersection) extends
ThetaSketchState {
- override def serialize(): Array[Byte] =
intersection.getResult.toByteArrayCompressed
- override def eval(): Array[Byte] =
intersection.getResult.toByteArrayCompressed
+ override def serialize(): Array[Byte] = {
Review Comment:
Optional simplification: `deserialize` already maps an empty byte array to a
fresh `createAggregationBuffer()`, and the new `!hasResult()` guard in `merge`
skips that fresh buffer. If `serialize` returned `Array.emptyByteArray` for an
untouched intersection, the `buffer == null` branch in `deserialize` and the
`case (_, null)` guard in `merge` wouldn't be needed (here and in
`tupleIntersectionAgg.scala`; the same change would apply to
`IntersectionTupleAggregationBuffer.serialize`). `deserialize` would also keep
returning a buffer object, as the `TypedImperativeAggregate` docs describe, and
this matches `HllUnionAgg`, which serializes its empty state as an empty array.
I traced the null path through `ObjectHashAggregateExec` (including the
sort-based fallback), `SortAggregateExec` and `AggregatingAccumulator` and
didn't find a problem, so this is about keeping the contract simple, not a bug.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/thetasketchesAggregates.scala:
##########
@@ -44,8 +44,22 @@ case class UnionAggregationBuffer(union: Union) extends
ThetaSketchState {
override def eval(): Array[Byte] = union.getResult.toByteArrayCompressed
}
case class IntersectionAggregationBuffer(intersection: Intersection) extends
ThetaSketchState {
- override def serialize(): Array[Byte] =
intersection.getResult.toByteArrayCompressed
- override def eval(): Array[Byte] =
intersection.getResult.toByteArrayCompressed
+ override def serialize(): Array[Byte] = {
+ // An untouched intersection represents no contribution, not an empty
sketch.
+ if (intersection.hasResult()) {
+ intersection.getResult.toByteArrayCompressed
+ } else {
+ null
+ }
+ }
+
+ override def eval(): Array[Byte] = {
+ if (intersection.hasResult()) {
+ intersection.getResult.toByteArrayCompressed
+ } else {
+ new UpdateSketchBuilder().build().compact().toByteArrayCompressed
Review Comment:
Since this is a user-facing change, could you document it in the
`ExpressionDescription` of `theta_intersection_agg`,
`tuple_intersection_agg_double` and `tuple_intersection_agg_integer`? For
example, a `note` such as "NULL input sketches are ignored. If a group has no
non-NULL input sketch, the result is an empty sketch." An example like `SELECT
theta_sketch_estimate(_FUNC_(sketch)) FROM VALUES (CAST(NULL AS BINARY))
tab(sketch);` returning `0` would also be run by `ExpressionInfoSuite`. It's
probably worth saying that the empty result is not neutral for a later
intersection: intersecting per-group results again gives an empty sketch if any
group had only NULL inputs.
In the PR description, a sentence on why the result is an empty sketch
rather than NULL would help reviewers: these functions are declared
non-nullable, and `theta_sketch_agg`, `theta_union_agg` and `tuple_union_agg_*`
already return an empty sketch for all-NULL input.
##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameAggregateSuite.scala:
##########
@@ -5031,6 +5031,62 @@ class DataFrameAggregateSuite extends SharedSparkSession
assert(estimateResult == 2.0)
}
+ private val intersectionSketchFunctions = Seq(
+ ("theta_sketch_agg(id)", "theta_intersection_agg",
"theta_sketch_estimate"),
+ ("tuple_sketch_agg_double(id, 1.0D)",
+ "tuple_intersection_agg_double", "tuple_sketch_estimate_double"),
+ ("tuple_sketch_agg_integer(id, 1)",
+ "tuple_intersection_agg_integer", "tuple_sketch_estimate_integer")
+ )
+
+ gridTest(
+ "intersection returns an empty sketch for all-null input")(
Review Comment:
Nit: since the grid parameter is the whole 3-tuple, every test name contains
all three SQL snippets. Using just the intersection function name would read
better. Also, please prefix new test names with `SPARK-59975:` like the
neighboring tests (`SPARK-54179: ...`). The same goes for the new tests in
`ThetasketchesAggSuite` and `TuplesketchAggSuite`.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/aggregate/TuplesketchAggSuite.scala:
##########
@@ -0,0 +1,159 @@
+/*
+ * 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.sql.catalyst.expressions.aggregate
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{BoundReference,
GenericInternalRow, Literal}
+import org.apache.spark.sql.catalyst.util.TupleSketchUtils
+import org.apache.spark.sql.types.{BinaryType, IntegerType}
+
+class TuplesketchAggSuite extends SparkFunSuite {
Review Comment:
Nit: `TupleSketchAggSuite` would match `tupleSketchAgg.scala` and
`TupleSketchUtilsSuite`. The theta tests in `ThetasketchesAggSuite` are nearly
the same as the ones generated by `testIntersection` here, so sharing that
helper would remove the duplication.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/aggregate/ThetasketchesAggSuite.scala:
##########
@@ -172,4 +197,99 @@ class ThetasketchesAggSuite extends SparkFunSuite {
.eval(InternalRow(intersectionResult))
assert(estimate.asInstanceOf[Long] >= 95 && estimate.asInstanceOf[Long] <=
105)
}
+
+ gridTest(
+ "theta intersection keeps no-input partials null and returns an empty
final")(
+ Seq(0, 2)) { numNulls =>
+ val agg = new ThetaIntersectionAgg(BoundReference(0, BinaryType, nullable
= true))
+ val buffer = createIntersectionBuffer(agg,
Seq.fill[Array[Byte]](numNulls)(null))
+ assert(agg.serialize(buffer) == null)
+ assert(agg.deserialize(null) == null)
+ checkIntersectionEstimate(agg, buffer, 0.0)
+
assert(agg.eval(buffer).asInstanceOf[Array[Byte]].sameElements(buildThetaSketch(Seq.empty)))
+
+ // Final evaluation must not change the untouched intermediate state.
+ assert(agg.serialize(buffer) == null)
+ val updated = agg.update(buffer, InternalRow(buildThetaSketch(Seq(1, 2))))
+ checkIntersectionEstimate(agg, updated, 2.0)
+ }
+
+ test("theta intersection preserves null through partial merge and final
merge") {
+ val agg = new ThetaIntersectionAgg(BoundReference(0, BinaryType, nullable
= true))
+ val partial = createIntersectionBuffer(agg, Seq(null, null))
+ val partialMerge = agg.merge(
+ agg.createAggregationBuffer(),
+ agg.deserialize(agg.serialize(partial)))
+ val merged = agg.merge(partialMerge,
agg.deserialize(agg.serialize(partial)))
+ assert(agg.serialize(merged) == null)
+
+ val result = agg.merge(
+ agg.createAggregationBuffer(),
+ agg.deserialize(agg.serialize(merged)))
+ checkIntersectionEstimate(agg, result, 0.0)
+ }
+
+ gridTest("theta intersection skips no-input partials (serialized,
nullFirst)")(
+ Seq((false, false), (false, true), (true, false), (true, true))) {
+ case (serialized, nullFirst) =>
+ val agg = new ThetaIntersectionAgg(BoundReference(0, BinaryType,
nullable = true))
+ val untouched = createIntersectionBuffer(agg, Seq(null, null))
+ val populated = createIntersectionBuffer(agg, Seq(null,
buildThetaSketch(Seq(1, 2)), null))
+ val partials = if (nullFirst) Seq(untouched, populated) else
Seq(populated, untouched)
+ val merged = partials.foldLeft(agg.createAggregationBuffer()) { (buffer,
partial) =>
+ val input = if (serialized) agg.deserialize(agg.serialize(partial))
else partial
+ agg.merge(buffer, input)
+ }
+ checkIntersectionEstimate(agg, merged, 2.0)
+
+ // A further partial-merge serialization must preserve the populated
result.
+ val result = agg.merge(
+ agg.createAggregationBuffer(),
+ agg.deserialize(agg.serialize(merged)))
+ checkIntersectionEstimate(agg, result, 2.0)
+ }
+
+ test("theta intersection skips untouched objects in mergeBuffersObjects") {
+ val agg = new ThetaIntersectionAgg(BoundReference(0, BinaryType, nullable
= true))
+ val destination = new GenericInternalRow(1)
+ val incoming = new GenericInternalRow(1)
+ agg.initialize(destination)
+ agg.initialize(incoming)
+ agg.update(destination, InternalRow(buildThetaSketch(Seq(1, 2))))
+ agg.update(incoming, InternalRow(null: Array[Byte]))
+
+ agg.mergeBuffersObjects(destination, incoming)
+ val result = agg.eval(destination).asInstanceOf[Array[Byte]]
+ assert(ThetaSketchUtils.wrapCompactSketch(result,
agg.prettyName).getEstimate == 2.0)
+ }
+
+ gridTest("theta intersection does not skip real empty partials, " +
Review Comment:
Nit: the gridTest names are inconsistent: `(serialized, emptyFirst) =` here
vs `(serialized, nullFirst)` without `=` above (same in `TuplesketchAggSuite`).
--
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]