dongjoon-hyun commented on code in PR #59697:
URL: https://github.com/apache/spark/pull/59697#discussion_r4371949209
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/catalog/SessionCatalog.scala:
##########
@@ -2073,17 +2077,63 @@ class SessionCatalog(
}
/**
- * Constructs a [[FunctionBuilder]] based on the provided function metadata.
+ * Constructs a [[FunctionBuilder]] and the [[ExpressionInfo]] based on the
provided function
+ * metadata.
*/
- private def makeFunctionBuilder(func: CatalogFunction): FunctionBuilder = {
+ private def makeFunctionBuilderAndInfo(
+ func: CatalogFunction): (FunctionBuilder, ExpressionInfo) = {
+ val clazz = loadFunctionClass(func)
+ val name = func.identifier.unquotedString
+ if (isV2FunctionClass(clazz)) {
+ // A DataSource V2 function: bind it to the arguments at each call site.
An aggregate
+ // function is returned as a bare `V2Aggregator`, which the caller wraps
into an
+ // `AggregateExpression` carrying DISTINCT / FILTER. This check goes
first so that a class
+ // also implementing a Hive UDF interface, for other engines sharing the
metastore, runs as
+ // a V2 function in Spark. The alias makes errors and column names use
the function name,
+ // like the Hive UDF wrappers do.
+ val unbound = newV2FunctionInstance(clazz, func.identifier)
+ val builder = (input: Seq[Expression]) => {
+ val expr = V2ExpressionUtils.bindFunction(unbound, input)
+ expr.setTagValue(FunctionRegistry.FUNC_ALIAS, name)
+ expr
+ }
+ (builder, makeExprInfoForV2Function(func, unbound))
Review Comment:
**A `description()` that throws now fails `CREATE TEMPORARY FUNCTION`,
`REFRESH FUNCTION` and every first call of a persistent function, although the
function itself works.** Since the last round, registration builds its info
here from the bound instance, and `unbound.description()` at L2262 runs outside
any `try`. Before that change, registration went through
`makeExprInfoForClassFunction`, whose `case NonFatal(_) | _: LinkageError` at
L2251 falls back to the class-name-only info. Take a class whose `bind` and
`produceResult` work but whose `description()` is `???` (a
`NotImplementedError`, which is `NonFatal`), a stub that throws
`UnsupportedOperationException`, or one that hits an NPE:
- `CREATE TEMPORARY FUNCTION f AS '...'` fails with the raw exception and no
error class, and an NPE is reported as `INTERNAL_ERROR`.
- `CREATE FUNCTION f AS '...'` succeeds, and SHOW, DESCRIBE and
`listFunctions` work through the fallback at L2251. But every `SELECT f(...)`
fails in the builder factory at L2835, and since nothing gets registered, the
next query fails again.
- The same class works in queries when a `FunctionCatalog` serves it,
because `resolveV2Function` never calls `description()`.
sunchao's review summary also relied on "Description failures fall back to
basic metadata, while invocation retains its normal failure behavior." I traced
this by reading the code and did not run it. Could this keep the reused
instance but guard the description, e.g. `try makeExprInfoForV2Function(func,
unbound) catch { case NonFatal(_) => makeExprInfoForHiveFunction(func) }`, and
add a test with a throwing `description()`?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/catalog/SessionCatalog.scala:
##########
@@ -2073,17 +2077,63 @@ class SessionCatalog(
}
/**
- * Constructs a [[FunctionBuilder]] based on the provided function metadata.
+ * Constructs a [[FunctionBuilder]] and the [[ExpressionInfo]] based on the
provided function
+ * metadata.
*/
- private def makeFunctionBuilder(func: CatalogFunction): FunctionBuilder = {
+ private def makeFunctionBuilderAndInfo(
+ func: CatalogFunction): (FunctionBuilder, ExpressionInfo) = {
+ val clazz = loadFunctionClass(func)
+ val name = func.identifier.unquotedString
+ if (isV2FunctionClass(clazz)) {
+ // A DataSource V2 function: bind it to the arguments at each call site.
An aggregate
+ // function is returned as a bare `V2Aggregator`, which the caller wraps
into an
+ // `AggregateExpression` carrying DISTINCT / FILTER. This check goes
first so that a class
+ // also implementing a Hive UDF interface, for other engines sharing the
metastore, runs as
+ // a V2 function in Spark. The alias makes errors and column names use
the function name,
+ // like the Hive UDF wrappers do.
+ val unbound = newV2FunctionInstance(clazz, func.identifier)
+ val builder = (input: Seq[Expression]) => {
+ val expr = V2ExpressionUtils.bindFunction(unbound, input)
Review Comment:
Minor: errors raised inside `bindFunction` still name the V2 object instead
of the registered function, because the alias is set only after it returns
(L2097). For example, after `CREATE TEMPORARY FUNCTION my_len AS
'org.apache.spark.sql.connector.V2StrLen'`, whose `name()` is `strlen`, `SELECT
my_len(1)` reports "Function 'strlen' cannot process input: (int): Expect
StringType.", while `SELECT my_len(DISTINCT 'a')` reports `my_len`.
`_LEGACY_ERROR_TEMP_1199` also uses the bound function's `name()`, and
`INVALID_UDF_IMPLEMENTATION` and `SCALAR_FUNCTION_NOT_FULLY_IMPLEMENTED`
(V2ExpressionUtils.scala:245 and :277) name the bound function, e.g.
`strlen_default`. No test checks a bind-time error for a function registered
under a name other than its `name()`. The `FunctionCatalog` route keeps
`unbound.name()`, which DataSourceV2FunctionSuite.scala:638 pins, so could
`bindFunction` take an optional display name that only this builder passes?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/catalog/SessionCatalog.scala:
##########
@@ -2073,17 +2077,63 @@ class SessionCatalog(
}
/**
- * Constructs a [[FunctionBuilder]] based on the provided function metadata.
+ * Constructs a [[FunctionBuilder]] and the [[ExpressionInfo]] based on the
provided function
+ * metadata.
*/
- private def makeFunctionBuilder(func: CatalogFunction): FunctionBuilder = {
+ private def makeFunctionBuilderAndInfo(
+ func: CatalogFunction): (FunctionBuilder, ExpressionInfo) = {
+ val clazz = loadFunctionClass(func)
+ val name = func.identifier.unquotedString
+ if (isV2FunctionClass(clazz)) {
+ // A DataSource V2 function: bind it to the arguments at each call site.
An aggregate
+ // function is returned as a bare `V2Aggregator`, which the caller wraps
into an
+ // `AggregateExpression` carrying DISTINCT / FILTER. This check goes
first so that a class
+ // also implementing a Hive UDF interface, for other engines sharing the
metastore, runs as
+ // a V2 function in Spark. The alias makes errors and column names use
the function name,
+ // like the Hive UDF wrappers do.
+ val unbound = newV2FunctionInstance(clazz, func.identifier)
+ val builder = (input: Seq[Expression]) => {
+ val expr = V2ExpressionUtils.bindFunction(unbound, input)
+ expr.setTagValue(FunctionRegistry.FUNC_ALIAS, name)
+ expr
+ }
+ (builder, makeExprInfoForV2Function(func, unbound))
+ } else {
+ val builder = (input: Seq[Expression]) =>
+ functionExpressionBuilder.makeExpression(name, clazz, input)
+ (builder, makeExprInfoForHiveFunction(func))
+ }
+ }
+
+ /**
+ * Loads the class a class-based function (`CREATE FUNCTION ... AS 'class'`)
refers to.
+ */
+ private def loadFunctionClass(func: CatalogFunction): Class[_] = {
val className = func.className
if (!Utils.classIsLoadable(className)) {
throw QueryCompilationErrors.cannotLoadClassWhenRegisteringFunctionError(
className, func.identifier)
}
- val clazz = Utils.classForName(className)
- val name = func.identifier.unquotedString
- (input: Seq[Expression]) => functionExpressionBuilder.makeExpression(name,
clazz, input)
+ Utils.classForName(className)
+ }
+
+ private def isV2FunctionClass(clazz: Class[_]): Boolean = {
+ classOf[UnboundFunction].isAssignableFrom(clazz)
+ }
+
+ private def newV2FunctionInstance(
+ clazz: Class[_],
+ ident: FunctionIdentifier): UnboundFunction = {
+ try {
+
clazz.getDeclaredConstructor().newInstance().asInstanceOf[UnboundFunction]
+ } catch {
+ case e: InvocationTargetException =>
Review Comment:
Minor: this arm wraps every cause, including fatal ones.
`Constructor.newInstance` wraps anything the constructor throws in an
`InvocationTargetException`, so an `OutOfMemoryError`, a `StackOverflowError`
or an `InterruptedException` (from a constructor declared `throws
InterruptedException`) becomes `CANNOT_INSTANTIATE_FUNCTION_CLASS`. On the
metadata paths, `case NonFatal(_) | _: LinkageError` at L2251 then catches that
`AnalysisException`, so DESCRIBE and `listFunctions` return normally while the
OOM or the interrupt is lost. cloud-fan's earlier comment asked that "Catching
LinkageError must not also swallow VirtualMachineError or other fatal
throwables; those must keep propagating", and this conversion gets around that
guard. Could this arm wrap only when `NonFatal(e.getCause) ||
e.getCause.isInstanceOf[LinkageError]`, and rethrow `e.getCause` otherwise?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/catalog/SessionCatalog.scala:
##########
@@ -2720,7 +2816,7 @@ class SessionCatalog(
val info = if (funcMetadata.isUserDefinedFunction) {
UserDefinedFunction.fromCatalogFunction(funcMetadata,
parser).toExpressionInfo
} else {
- makeExprInfoForHiveFunction(funcMetadata)
+ makeExprInfoForClassFunction(funcMetadata)
Review Comment:
Minor: on the query path, this eager info constructs the class one extra
time. `FunctionResolution` only calls `invoke` on the returned `V1Function`
(FunctionResolution.scala:170-171), and the builder factory then constructs
another instance at L2835. So the first query in a session that calls a
persistent V2 function without `USING` resources runs its constructor and
`description()` twice, and twice per query while the constructor keeps failing.
`spark.catalog.functionExists`, which only checks that the name resolves,
constructs it on every call until the first invocation registers it. This is
what remains of sunchao's construction count after the last round. Could
`V1Function` take its info lazily, e.g. as `() => ExpressionInfo`, so that only
DESCRIBE and the catalog APIs build it and the first call constructs the class
once?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/catalog/SessionCatalog.scala:
##########
@@ -2167,12 +2222,53 @@ class SessionCatalog(
if (funcDefinition.isUserDefinedFunction) {
UserDefinedFunction.fromCatalogFunction(funcDefinition,
parser).toExpressionInfo
} else {
- makeExprInfoForHiveFunction(funcDefinition)
+ makeExprInfoForClassFunction(funcDefinition)
}
}
registry.registerFunction(identToRegister, resolvedInfo, functionBuilder)
}
+ /**
+ * Builds the [[ExpressionInfo]] of a class-based function (`CREATE FUNCTION
... AS 'class'`).
+ * A DataSource V2 function reports its own description, which requires
instantiating it.
+ * Otherwise, or if the class cannot be loaded or instantiated, use the
class-name-only info, so
+ * that like a Hive UDF, a broken function only fails when invoked. The
class of a function with
+ * `USING` resources is not touched, as initializing it may require those
resources, which are
+ * only loaded on first invocation.
+ */
+ private def makeExprInfoForClassFunction(func: CatalogFunction):
ExpressionInfo = {
+ if (func.resources.nonEmpty) {
+ return makeExprInfoForHiveFunction(func)
+ }
+ try {
+ if (Utils.classIsLoadableAndAssignableFrom(func.className,
classOf[UnboundFunction])) {
Review Comment:
Minor: metadata lookups now touch the class of every class-based function
without `USING` resources on every call, and nothing caches the result. Before
this PR, `lookupPersistentFunction` and the eager info in
`loadPersistentScalarFunction` used `makeExprInfoForHiveFunction`, which never
loads the class. Now:
- every DESCRIBE before first use, `spark.catalog.listFunctions()` and
`getFunction` (classic and Connect), and Thrift `GetFunctions`
(SparkGetFunctionsOperation.scala:83) construct each unused V2 function and
call its `description()`, once per function per call, because only the first
invocation or `REFRESH FUNCTION` registers it;
- a plain Hive UDF whose class is not on Spark's classpath (e.g. one that
relies on `hive.aux.jars.path` and has no resource URIs) gets a failed
`Class.forName` and a new `ClassNotFoundException` on every lookup;
- each check looks the class up twice, in `classIsLoadableAndAssignableFrom`
and again in `Utils.classForName` at L2245.
With N such functions, one listing does N constructions and 2N lookups, and
every Thrift or Connect session repeats it. A light constructor disappears next
to the two metastore calls per function, but a heavy one slows down every
listing. I traced this by reading the code and did not run it. Could the V2
info be memoized per session, keyed by the identifier and the class name and
dropped on DROP, ALTER, REFRESH and CREATE OR REPLACE, and could L2244-2245
become one `Utils.classForName(func.className, initialize = false)` followed by
`isV2FunctionClass(clazz)` inside the same `try`?
##########
docs/sql-migration-guide.md:
##########
@@ -59,6 +59,7 @@ license: |
- Since Spark 4.4, with whole-stage codegen enabled, a filter condition,
including the `FILTER` clause of an aggregate function, evaluates the remaining
top-level conjuncts of the condition on a row after one of them evaluates to
`NULL`, as the SQL standard requires, and does so in the order they appear in
the condition, as Spark already did with whole-stage codegen disabled, which
also evaluates a subexpression shared by several conjuncts before the
condition. Only a `FALSE` conjunct stops the evaluation. One exception: an `IS
NOT NULL` check on a column, or on an expression over it, that a later conjunct
references is still evaluated before that conjunct and the conjuncts up to the
check's own position, even when it is written after them, and can drop the row
before an error one of them raises. Once a condition has a conjunct that can be
`NULL`, the `IS NOT NULL` checks after that conjunct are evaluated in the order
of the condition on every row, not only on rows where a conjunct
is `NULL`. A row whose condition is `NULL` is still dropped, but an error
raised by a later conjunct on that row, such as `ARITHMETIC_OVERFLOW` in ANSI
mode, now fails the query. For example, `SELECT a FROM t WHERE NOT (a > 0 AND b
> 0) AND y + 1 > 0` can fail with `ARITHMETIC_OVERFLOW` on a row where `a` is
`NULL`, `b` is `1`, and `y` is `2147483647`, unless the row is dropped earlier
by a `FALSE` conjunct or `IS NOT NULL` check that precedes `y + 1 > 0` in the
condition, or skipped before the filter, for example by data source filter
pushdown such as Parquet row group statistics. Previously the query skipped
such a row with whole-stage codegen enabled. The later conjuncts also run on
more rows than before, so a nondeterministic conjunct such as `rand(seed)` can
return different values on the remaining rows, and a side-effecting
user-defined function is called on rows that are dropped. To restore the
previous behavior, set `spark.sql.legacy.filterShortCircuitOnNullConjunct` to
`tru
e`.
- Since Spark 4.4, when a format is specified, `to_timestamp`,
`try_to_timestamp`, and `to_timestamp_ltz` cast a numeric first argument to a
string and parse it with the format, for example `to_timestamp(20201231,
'yyyyMMdd')` returns `2020-12-31 00:00:00`. This matches the existing behavior
when `spark.sql.timestampType` is `TIMESTAMP_NTZ`. Previously, with the default
`TIMESTAMP_LTZ` timestamp type, such calls failed with `INTERNAL_ERROR`. Note
that the string form of the number is parsed, so a DECIMAL `1.5` is parsed as
`"1.5"`.
+- Since Spark 4.4, a function created by `CREATE FUNCTION ... AS 'class'`
whose class both extends a Hive UDF class (such as `UDF` or `GenericUDF`) and
implements `org.apache.spark.sql.connector.catalog.functions.UnboundFunction`
runs as a DataSource V2 function instead of a Hive UDF, including functions
created in earlier versions. Its result type, argument coercion, and null
handling are now defined by `UnboundFunction.bind` instead of the Hive UDF.
Review Comment:
Minor: this note covers only part of the change. The V2 check at
SessionCatalog.scala:2087 runs before `functionExpressionBuilder`, so:
- a class that extends
`org.apache.spark.sql.expressions.UserDefinedAggregateFunction` (handled by
`SparkUDFExpressionBuilder` at BaseSessionStateBuilder.scala:503) and
implements `UnboundFunction` also switches, from `ScalaUDAF` to `V2Aggregator`;
- determinism and constant folding change too. A Hive wrapper is
deterministic only when its `@UDFType` says so (hiveUDFEvaluators.scala:46-48),
and a `GenericUDF` call is foldable only with a constant return inspector
(hiveUDFs.scala:141-142). As a V2 function it uses
`BoundFunction.isDeterministic()`, which defaults to `true`, and
`ApplyFunctionExpression` is foldable whenever its arguments are
(ApplyFunctionExpression.scala:35-37). So a `@UDFType(deterministic = false)`
class that does not override `isDeterministic()` becomes deterministic, and
`f('k')` can be folded once on the driver during planning instead of running
per row.
Could the note say "extends a Hive UDF class (such as `UDF` or `GenericUDF`)
or `UserDefinedAggregateFunction`", and add determinism to the semantics that
`UnboundFunction.bind` now defines?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala:
##########
@@ -796,80 +792,41 @@ class FunctionResolution(
unbound: UnboundFunction,
arguments: Seq[Expression],
unresolvedFunc: UnresolvedFunction): Expression = {
- val inputType = StructType(arguments.zipWithIndex.map {
- case (exp, pos) => StructField(s"_$pos", exp.dataType, exp.nullable)
- })
- val bound = try {
- unbound.bind(inputType)
- } catch {
- case unsupported: UnsupportedOperationException =>
- throw QueryCompilationErrors.functionCannotProcessInputError(
- unbound,
- arguments,
- unsupported
- )
- }
-
- if (bound.inputTypes().length != arguments.length) {
- throw
QueryCompilationErrors.v2FunctionInvalidInputTypeLengthError(bound, arguments)
- }
-
- bound match {
- case scalarFunc: ScalarFunction[_] =>
- processV2ScalarFunction(scalarFunc, arguments, unresolvedFunc)
- case aggFunc: V2AggregateFunction[_, _] =>
- processV2AggregateFunction(aggFunc, arguments, unresolvedFunc)
- case _ =>
- failAnalysis(
- errorClass = "INVALID_UDF_IMPLEMENTATION",
- messageParameters = Map("funcName" -> toSQLId(bound.name()))
- )
+ V2ExpressionUtils.bindFunction(unbound, arguments) match {
+ case aggregator: V2Aggregator[_, _] =>
+ processV2AggregateFunction(aggregator, unresolvedFunc)
+ case scalarExpr =>
+ checkV2ScalarFunctionSyntax(unbound.name(), unresolvedFunc)
+ scalarExpr
}
}
- private def processV2ScalarFunction(
- scalarFunc: ScalarFunction[_],
- arguments: Seq[Expression],
- unresolvedFunc: UnresolvedFunction): Expression = {
+ private def checkV2ScalarFunctionSyntax(
Review Comment:
Nit: after the split, this is the same check as
`checkUnsupportedAggregateClause` at L732-751: the same three conditions in the
same order, with the same syntax strings, and only the name parameter differs
(a `String` here, `func.prettyName` there). Every branch throws, so the `else
if` chain and the independent `if`s behave the same. Could
`checkUnsupportedAggregateClause` take the name as a `String`, with L685 and
L717 passing `agg.prettyName` and `other.prettyName` and L799 passing
`unbound.name()`? The error names stay exactly as they are now.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/catalog/SessionCatalog.scala:
##########
@@ -2736,12 +2832,9 @@ class SessionCatalog(
makeUserDefinedScalarFuncBuilder(udf))
} else {
loadFunctionResources(funcMetadata.resources)
+ val (builder, loadedInfo) =
makeFunctionBuilderAndInfo(funcMetadata)
Review Comment:
Nit: these lines repeat the `case None` branch of the public
`registerFunction` at L2175-2177, which builds the builder and the info from
one instance and registers them with `Some(info)` in the same
`functionRegistry`. Could L2834-2837 be
`loadFunctionResources(funcMetadata.resources)` followed by
`registerFunction(funcMetadata, overrideIfExists = false)`? The two-argument
call can only bind to the public overload, the next change to class-function
registration would then be made in one place, and `loadedInfo`, which only
avoids shadowing `info` at L2816, goes away.
--
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]