blob: 96b16aea9a999b8efc3d3d24aff358f3ae89668f [file]
#
# 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.
#
import unittest
from decimal import Decimal
from pyspark.errors import AnalysisException, PySparkValueError
from pyspark.sql import functions as sf
from pyspark.sql.types import (
DecimalType,
DoubleType,
LongType,
StructField,
StructType,
)
from pyspark.sql.window import Window
from pyspark.testing.sqlutils import ReusedSQLTestCase
from pyspark.testing.utils import (
have_pyarrow,
pyarrow_requirement_message,
)
from pyspark.util import PythonEvalType
if have_pyarrow:
from pyspark.sql.aggregator import Aggregator
from pyspark.sql.functions import udaf
class Mean(Aggregator):
@property
def bufferSchema(self):
return StructType([StructField("sum", DoubleType()), StructField("count", LongType())])
@property
def outputType(self):
return DoubleType()
def zero(self):
return (0.0, 0)
def reduce(self, buffer, value):
(v,) = value
if v is None: # ignore nulls, like SQL avg
return buffer
return (buffer[0] + v, buffer[1] + 1)
def merge(self, b1, b2):
return (b1[0] + b2[0], b1[1] + b2[1])
def finish(self, buffer):
return buffer[0] / buffer[1] if buffer[1] else None
class DecimalSum(Aggregator):
# Non-trivial output/buffer type: the result column and the intermediate buffer are both
# DecimalType, exercising explicit Arrow typing of the emitted arrays (a bare
# ``pa.array([Decimal(...)])`` would infer a decimal type whose precision/scale need not
# match the declared one).
@property
def bufferSchema(self):
return StructType([StructField("total", DecimalType(20, 4))])
@property
def outputType(self):
return DecimalType(20, 4)
def zero(self):
return (Decimal(0),)
def reduce(self, buffer, value):
(v,) = value
return buffer if v is None else (buffer[0] + Decimal(str(v)),)
def merge(self, b1, b2):
return (b1[0] + b2[0],)
def finish(self, buffer):
return buffer[0]
class SumSquares(Aggregator):
@property
def bufferSchema(self):
return StructType([StructField("sumsq", DoubleType())])
@property
def outputType(self):
return DoubleType()
def zero(self):
return (0.0,)
def reduce(self, buffer, value):
(v,) = value
return (buffer[0] + float(v) * float(v),)
def merge(self, b1, b2):
return (b1[0] + b2[0],)
def finish(self, buffer):
return buffer[0]
@unittest.skipIf(not have_pyarrow, pyarrow_requirement_message)
class ArrowPythonAggregatorTestsMixin:
def _data(self):
# 100 rows across 5 keys; repartition so each key is split across partitions,
# exercising map-side PARTIAL combine + post-shuffle FINAL merge.
return (
self.spark.range(0, 100)
.select((sf.col("id") % 5).alias("k"), sf.col("id").cast("double").alias("v"))
.repartition(4, sf.col("v") % 3)
)
def test_incremental_aggregator_matches_builtin_mean(self):
df = self._data()
result = df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
expected = df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
got = {r["k"]: r["m"] for r in result}
exp = {r["k"]: r["m"] for r in expected}
self.assertEqual(got, exp)
def test_incremental_aggregator_no_group(self):
df = self._data()
result = df.agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
expected = df.agg(sf.avg("v").alias("m")).collect()
self.assertAlmostEqual(result[0]["m"], expected[0]["m"], places=6)
def test_incremental_aggregator_empty_global_input(self):
# A global aggregation over empty input must still return one identity row: finish(zero).
empty = self._data().limit(0)
result = empty.agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
expected = empty.agg(sf.avg("v").alias("m")).collect()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["m"], expected[0]["m"])
def test_incremental_aggregator_custom_buffer(self):
df = self._data()
result = (
df.groupBy("k").agg(udaf(SumSquares())(sf.col("v")).alias("s")).orderBy("k").collect()
)
expected = (
df.groupBy("k").agg(sf.sum(sf.col("v") * sf.col("v")).alias("s")).orderBy("k").collect()
)
got = {r["k"]: r["s"] for r in result}
exp = {r["k"]: r["s"] for r in expected}
for k in exp:
self.assertAlmostEqual(got[k], exp[k], places=6)
def test_incremental_aggregator_decimal_output(self):
# Non-trivial output/buffer type (DecimalType), crossing the shuffle as a decimal buffer
# and emitted as a decimal result -- guards the explicit Arrow typing of both stages.
df = self._data()
result = (
df.groupBy("k").agg(udaf(DecimalSum())(sf.col("v")).alias("s")).orderBy("k").collect()
)
expected = df.groupBy("k").agg(sf.sum("v").alias("s")).orderBy("k").collect()
got = {r["k"]: r["s"] for r in result}
exp = {r["k"]: r["s"] for r in expected}
for k in exp:
self.assertIsInstance(got[k], Decimal)
self.assertAlmostEqual(float(got[k]), exp[k], places=4)
def test_incremental_aggregator_null_inputs(self):
# reduce must tolerate null input values; the null-skipping Mean should match SQL avg,
# including a group whose values are all null (identity buffer -> finish returns None).
df = self.spark.createDataFrame(
[("a", 1.0), ("a", None), ("a", 3.0), ("b", None), ("b", None)],
"k string, v double",
)
result = df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
expected = df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
got = {r["k"]: r["m"] for r in result}
exp = {r["k"]: r["m"] for r in expected}
self.assertEqual(got, exp)
def test_multiple_incremental_aggregators(self):
# Two aggregators with different buffer schemas over the same input in one agg call.
df = self._data()
result = (
df.groupBy("k")
.agg(
udaf(Mean())(sf.col("v")).alias("m"),
udaf(SumSquares())(sf.col("v")).alias("s"),
)
.orderBy("k")
.collect()
)
expected = (
df.groupBy("k")
.agg(
sf.avg("v").alias("m"),
sf.sum(sf.col("v") * sf.col("v")).alias("s"),
)
.orderBy("k")
.collect()
)
got_m = {r["k"]: r["m"] for r in result}
got_s = {r["k"]: r["s"] for r in result}
for r in expected:
self.assertAlmostEqual(got_m[r["k"]], r["m"], places=6)
self.assertAlmostEqual(got_s[r["k"]], r["s"], places=6)
def test_result_independent_of_partition_count(self):
# Partial buffers must merge to the same result regardless of how keys are split.
base = self.spark.range(0, 60).select(
(sf.col("id") % 3).alias("k"), sf.col("id").cast("double").alias("v")
)
results = []
for n in (1, 2, 7):
rows = (
base.repartition(n, sf.col("v"))
.groupBy("k")
.agg(udaf(Mean())(sf.col("v")).alias("m"))
.orderBy("k")
.collect()
)
results.append({r["k"]: r["m"] for r in rows})
self.assertEqual(results[0], results[1])
self.assertEqual(results[1], results[2])
def test_sql_registration(self):
# Register the aggregator and invoke it from SQL text.
df = self._data()
df.createOrReplaceTempView("agg_input")
self.spark.udf.register("my_mean", udaf(Mean()))
result = self.spark.sql(
"SELECT k, my_mean(v) AS m FROM agg_input GROUP BY k ORDER BY k"
).collect()
expected = df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
got = {r["k"]: r["m"] for r in result}
exp = {r["k"]: r["m"] for r in expected}
self.assertEqual(got, exp)
def test_named_arguments(self):
# A named argument (both DataFrame and SQL forms) must feed the aggregator's value tuple,
# not be silently dropped.
df = self._data()
result = df.groupBy("k").agg(udaf(Mean())(v=sf.col("v")).alias("m")).orderBy("k").collect()
expected = df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
self.assertEqual({r["k"]: r["m"] for r in result}, {r["k"]: r["m"] for r in expected})
df.createOrReplaceTempView("agg_input")
self.spark.udf.register("my_mean", udaf(Mean()))
sql_result = self.spark.sql(
"SELECT k, my_mean(v => v) AS m FROM agg_input GROUP BY k ORDER BY k"
).collect()
self.assertEqual({r["k"]: r["m"] for r in sql_result}, {r["k"]: r["m"] for r in expected})
def test_distinct_and_filter_rejected(self):
# Neither DISTINCT nor FILTER is honored by the two-stage operator, so both must be
# rejected at analysis rather than silently returning the non-distinct/unfiltered result.
df = self._data()
df.createOrReplaceTempView("agg_input")
self.spark.udf.register("my_mean", udaf(Mean()))
with self.assertRaises(AnalysisException):
self.spark.sql("SELECT my_mean(DISTINCT v) FROM agg_input GROUP BY k").collect()
with self.assertRaises(AnalysisException):
self.spark.sql(
"SELECT my_mean(v) FILTER (WHERE v > 0) FROM agg_input GROUP BY k"
).collect()
def test_pivot_rejected(self):
# ResolvePivot must reject the incremental aggregator (its null-ignoring fallback rewrite
# would produce wrong results), like it already rejects pandas UDAFs.
df = self.spark.createDataFrame(
[("a", "x", 1.0), ("a", "y", 2.0), ("b", "x", 3.0)],
"k string, p string, v double",
)
with self.assertRaises(AnalysisException):
df.groupBy("k").pivot("p").agg(udaf(Mean())(sf.col("v"))).collect()
def test_window_unbounded(self):
# Unbounded partition frame: every row gets its whole group's aggregate. Cross-checked
# against the equivalent SQL window aggregate.
df = self._data()
w = Window.partitionBy("k")
result = df.withColumn("m", udaf(Mean())(sf.col("v")).over(w)).orderBy("k", "v").collect()
expected = df.withColumn("m", sf.avg("v").over(w)).orderBy("k", "v").collect()
self.assertEqual(len(result), len(expected))
for r, e in zip(result, expected):
self.assertAlmostEqual(r["m"], e["m"], places=6)
def test_window_running_frame(self):
# Ordered, growing frame (unbounded preceding .. current row): a running aggregate that
# exercises the per-row bounded-frame path in the worker.
df = self._data()
w = (
Window.partitionBy("k")
.orderBy("v")
.rowsBetween(Window.unboundedPreceding, Window.currentRow)
)
result = df.withColumn("m", udaf(Mean())(sf.col("v")).over(w)).orderBy("k", "v").collect()
expected = df.withColumn("m", sf.avg("v").over(w)).orderBy("k", "v").collect()
self.assertEqual(len(result), len(expected))
for r, e in zip(result, expected):
self.assertAlmostEqual(r["m"], e["m"], places=6)
def test_window_sliding_frame(self):
# Sliding frame (1 preceding .. 1 following) with a custom single-field buffer aggregator.
df = self._data()
w = Window.partitionBy("k").orderBy("v").rowsBetween(-1, 1)
result = (
df.withColumn("s", udaf(SumSquares())(sf.col("v")).over(w)).orderBy("k", "v").collect()
)
expected = (
df.withColumn("s", sf.sum(sf.col("v") * sf.col("v")).over(w))
.orderBy("k", "v")
.collect()
)
self.assertEqual(len(result), len(expected))
for r, e in zip(result, expected):
self.assertAlmostEqual(r["s"], e["s"], places=4)
def test_window_bounded_preceding_frame(self):
# A fixed number of preceding rows exercises both branches of the running-buffer
# optimization: the lower bound is clamped to 0 for the first rows (running buffer is
# extended in place) and then advances (each frame is refolded from zero).
df = self._data()
w = Window.partitionBy("k").orderBy("v").rowsBetween(-3, Window.currentRow)
result = df.withColumn("m", udaf(Mean())(sf.col("v")).over(w)).orderBy("k", "v").collect()
expected = df.withColumn("m", sf.avg("v").over(w)).orderBy("k", "v").collect()
self.assertEqual(len(result), len(expected))
for r, e in zip(result, expected):
self.assertAlmostEqual(r["m"], e["m"], places=6)
def test_window_decimal_output(self):
# A non-trivial (Decimal) output type over a window, exercising the explicit
# ``pa.array(..., type=result_type)`` typing on the window path.
df = self._data()
w = Window.partitionBy("k")
result = (
df.withColumn("s", udaf(DecimalSum())(sf.col("v")).over(w)).orderBy("k", "v").collect()
)
expected = df.withColumn("s", sf.sum("v").over(w)).orderBy("k", "v").collect()
self.assertEqual(len(result), len(expected))
for r, e in zip(result, expected):
self.assertIsInstance(r["s"], Decimal)
self.assertAlmostEqual(float(r["s"]), e["s"], places=4)
def test_window_mixed_python_udf_rejected(self):
# An incremental aggregator and a grouped-agg pandas UDF over the same window are both
# Python window functions but use different eval types, so they cannot share one operator.
# This must raise a clear analysis error rather than an internal assertion.
from pyspark.sql.functions import PandasUDFType, pandas_udf
@pandas_udf("double", PandasUDFType.GROUPED_AGG)
def pandas_mean(v):
return v.mean()
df = self._data()
w = Window.partitionBy("k")
with self.assertRaises(AnalysisException) as ctx:
df.select(udaf(Mean())(sf.col("v")).over(w), pandas_mean(sf.col("v")).over(w)).collect()
self.assertEqual(
ctx.exception.getCondition(),
"UNSUPPORTED_FEATURE.MULTIPLE_PYTHON_UDF_TYPES_IN_WINDOW",
)
def test_mixed_with_other_aggregate_rejected(self):
# An incremental aggregator mixed with another aggregate in one Aggregate is unsupported;
# the error must be the dedicated (non-pandas) placement error.
df = self._data()
with self.assertRaises(AnalysisException) as ctx:
df.groupBy("k").agg(
udaf(Mean())(sf.col("v")).alias("m"), sf.count("*").alias("c")
).collect()
self.assertEqual(ctx.exception.getCondition(), "INVALID_PYTHON_UDF_PLACEMENT")
def test_duplicate_buffer_field_names_rejected(self):
# Duplicate buffer field names silently collapse on the map side and then fail with an
# opaque Arrow error post-shuffle; reject them where the aggregator is created.
class DupBuffer(Mean):
@property
def bufferSchema(self):
return StructType([StructField("x", DoubleType()), StructField("x", LongType())])
with self.assertRaises(PySparkValueError) as ctx:
udaf(DupBuffer())
self.check_error(
exception=ctx.exception,
errorClass="DUPLICATED_FIELD_NAME_IN_ARROW_STRUCT",
messageParameters={"field_names": "x"},
)
def test_float_grouping_keys_normalized(self):
# 0.0 / -0.0 must fall into one group, and NaN keys (unequal to themselves) must group
# together, matching SQL aggregate semantics -- guards grouping-key normalization and the
# NaN handling of the map-side hash combine.
zeros = self.spark.createDataFrame(
[(0.0, 1.0), (-0.0, 2.0), (0.0, 3.0)], "k double, v double"
)
zero_rows = zeros.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
self.assertEqual(len(zero_rows), 1)
self.assertAlmostEqual(zero_rows[0]["m"], 2.0, places=6)
nans = self.spark.createDataFrame(
[(float("nan"), 1.0), (float("nan"), 3.0)], "k double, v double"
)
nan_rows = nans.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
self.assertEqual(len(nan_rows), 1)
self.assertAlmostEqual(nan_rows[0]["m"], 2.0, places=6)
def test_complex_grouping_key(self):
# A struct grouping key exercises the map-side hash combine's canonicalization of complex
# keys (dict -> hashable) as well as the authoritative FINAL re-grouping.
df = self.spark.createDataFrame(
[(1, "a", 1.0), (1, "a", 3.0), (2, "b", 10.0)],
"i int, s string, v double",
).select(sf.struct("i", "s").alias("k"), sf.col("v"))
result = df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
got = {(r["k"]["i"], r["k"]["s"]): r["m"] for r in result}
self.assertEqual(got, {(1, "a"): 2.0, (2, "b"): 10.0})
def test_bounded_map_side_combine(self):
# A small maxRecordsPerBatch forces the map-side PARTIAL stage to flush its per-key buffer
# in bounded chunks (emitting duplicate keys that the FINAL stage re-merges authoritatively)
# instead of holding every key for the whole partition. The result must be unchanged --
# this guards the cap/flush + chunked-emission path against OOM on high-cardinality keys.
df = self._data()
with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": 2}):
result = (
df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
)
expected = df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
self.assertEqual({r["k"]: r["m"] for r in result}, {r["k"]: r["m"] for r in expected})
def test_missing_buffer_schema_rejected(self):
# udaf() enforces a struct buffer schema up front, but a low-level construction (or a
# malformed Connect proto) can build an incremental aggregator UDF without one. That must
# surface a classed planner error, not a bare IllegalArgumentException / ClassCastException.
from pyspark.sql.utils import is_remote
if is_remote():
from pyspark.sql.connect.udf import UserDefinedFunction
else:
from pyspark.sql.udf import UserDefinedFunction # type: ignore[assignment]
bad = UserDefinedFunction(
Mean(),
returnType=DoubleType(),
name="bad_mean",
evalType=PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
deterministic=True,
)._wrapped()
df = self._data()
with self.assertRaises(AnalysisException) as ctx:
df.groupBy("k").agg(bad(sf.col("v"))).collect()
self.assertEqual(ctx.exception.getCondition(), "INVALID_PYTHON_AGGREGATOR_BUFFER_SCHEMA")
def test_mixed_pandas_udaf_and_incremental_rejected(self):
# Mixing a grouped-agg pandas UDAF with an incremental aggregator in one Aggregate is
# unsupported. It falls through to the dedicated placement error, which must name BOTH
# offending functions rather than dropping the co-offending pandas UDAF.
from pyspark.sql.functions import PandasUDFType, pandas_udf
@pandas_udf("double", PandasUDFType.GROUPED_AGG)
def pandas_mean(v):
return v.mean()
df = self._data()
with self.assertRaises(AnalysisException) as ctx:
df.groupBy("k").agg(
udaf(Mean())(sf.col("v")).alias("m"),
pandas_mean(sf.col("v")).alias("pm"),
).collect()
self.assertEqual(ctx.exception.getCondition(), "INVALID_PYTHON_UDF_PLACEMENT")
message = ctx.exception.getMessage()
self.assertIn("Mean", message)
self.assertIn("pandas_mean", message)
class ArrowPythonAggregatorTests(ArrowPythonAggregatorTestsMixin, ReusedSQLTestCase):
pass
@unittest.skipIf(not have_pyarrow, pyarrow_requirement_message)
class ArrowPythonAggregatorProfilerTests(unittest.TestCase):
# Profiling is not supported for incremental aggregators: their ``func`` is an ``Aggregator``
# object, so the profiler wrappers would either break the worker (a plain function has no
# ``bufferSchema`` / ``zero`` / ``reduce``) or fail on the driver in
# ``inspect.getsourcelines(f.__code__)``. Enabling a profiler must therefore fall back to the
# non-profiled path (with a warning) and still compute the correct result, not crash. These
# confs are set at session creation, so this needs its own session (classic only).
def _run(self, conf_key):
import warnings
from pyspark import SparkConf
from pyspark.sql import SparkSession
conf = SparkConf().set(conf_key, "true")
spark = (
SparkSession.builder.master("local[4]")
.config(conf=conf)
.appName(self.__class__.__name__)
.getOrCreate()
)
try:
df = spark.range(0, 20).select(
(sf.col("id") % 3).alias("k"), sf.col("id").cast("double").alias("v")
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
result = (
df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
)
expected = df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
self.assertEqual({r["k"]: r["m"] for r in result}, {r["k"]: r["m"] for r in expected})
self.assertTrue(
any("incremental Python aggregators" in str(w.message) for w in caught),
"expected an unsupported-profiling warning",
)
finally:
spark.stop()
def test_cpu_profiler_falls_back(self):
self._run("spark.python.profile")
def test_memory_profiler_falls_back(self):
self._run("spark.python.profile.memory")
if __name__ == "__main__":
from pyspark.testing import main
main()