cloud-fan commented on code in PR #58549:
URL: https://github.com/apache/spark/pull/58549#discussion_r3960245814


##########
python/pyspark/sql/udf.py:
##########
@@ -314,9 +317,24 @@ def _conf_is_true(key: str, default: Optional[str] = None) 
-> bool:
 
     @staticmethod
     def _check_return_type(returnType: DataType, evalType: int) -> None:
+        char_varchar_supported_eval_types = (
+            PythonEvalType.SQL_ARROW_BATCHED_UDF,
+            PythonEvalType.SQL_SCALAR_PANDAS_UDF,
+            PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
+            PythonEvalType.SQL_SCALAR_ARROW_UDF,
+            PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF,
+        )
+
+        def check_arrow_type() -> None:
+            if evalType not in char_varchar_supported_eval_types and _has_type(

Review Comment:
   **Blocking (P1):** The CHAR/VARCHAR rejection is reachable only from the 
eval-type branches below, so public incremental aggregate and 
TransformWithState variants currently fall through without either rejection or 
assignment checks in their consumers. A VARCHAR(3) result can therefore emit 
`abcd`, and a CHAR result remains unpadded. Please make this capability 
dispatch exhaustive: only explicitly supported scalar modes should accept 
CHAR/VARCHAR, and every other eval type should reject recursively unless its 
executor enforces the same semantics. Add focused incremental/stateful cases so 
future eval types cannot silently fall through.



##########
python/pyspark/sql/connect/udtf.py:
##########
@@ -167,9 +172,21 @@ def __init__(
         self.evalType = evalType
         self.deterministic = deterministic
 
+    def _check_return_type(self) -> None:
+        if self.returnType is None:

Review Comment:
   **Blocking (P1):** `returnType=None` is the supported dynamic-schema form 
for a regular UDTF with `analyze()`, including the Arrow-optimized 
SQL_ARROW_TABLE_UDF path. Returning here means a schema containing CHAR/VARCHAR 
is never passed to the new rejection and instead reaches execution with a 
logical constrained type backed by an Arrow STRING vector. Please validate the 
effective schema after analyze in both classic and Connect, or explicitly 
disable Arrow optimization for this schema source, and cover nested constrained 
types.
   
   **Recommended change:** Apply the same recursive Arrow UDTF capability check 
to the schema produced by analyze before constructing the resolved UDTF.
   
   **Why this works:** Place validation at the shared effective-schema boundary 
reached by classic and Connect analyze resolution; reject CHAR/VARCHAR before 
Arrow planning while leaving non-Arrow analyze behavior unchanged.
   
   **Scope:** Classic and Connect dynamic UDTF resolution plus focused 
analyze-based Arrow UDTF tests.
   
   **Compatibility:** Explicit-schema behavior and supported non-Arrow analyze 
UDTFs remain unchanged; unsupported Arrow constrained-string schemas fail early 
with the intended error.
   
   **Risks:** Validating only one frontend would leave classic and Connect 
behavior inconsistent. Validating after physical planning would preserve the 
misleading runtime type mismatch.
   
   **Constraints:** Use the effective analyzed schema rather than requiring an 
explicit returnType. Preserve analyze support for schemas without CHAR/VARCHAR.
   
   **Success:** Explicit and analyze-derived Arrow UDTF schemas receive 
identical recursive CHAR/VARCHAR rejection in classic and Connect.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -79,6 +81,22 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
     sessionUUID: Option[String])
   extends PartitionEvaluatorFactory[ColumnarBatch, ColumnarBatch] {
 
+  private val applyCharVarcharChecks =
+    CharVarcharUtils.shouldApplyWriteSideLengthCheck(SQLConf.get)
+  private val checkedOutput = if (applyCharVarcharChecks) {
+    childOutput ++ output.drop(childOutput.length).map { attr =>
+      CharVarcharUtils.stringLengthCheck(attr, attr.dataType)
+    }
+  } else {
+    output
+  }
+  private val hasCharVarcharOutput =
+    applyCharVarcharChecks &&
+      output.drop(childOutput.length).exists(attr => 
CharVarcharUtils.hasCharVarchar(attr.dataType))
+  private val physicalOutputSchema = CharVarcharUtils

Review Comment:
   **Blocking (P1):** When any sibling output contains CHAR/VARCHAR, the 
row-queue path converts the entire joined output with this schema. Unlike 
`outputTypes` below, this schema leaves UserDefinedType values wrapped, but 
RowToColumnConverter only accepts their physical SQL types. Thus a valid Arrow 
UDF returning ExamplePointUDT starts failing merely because a sibling UDF 
returns CHAR. Please recursively replace each UDT with its `sqlType` before 
replacing CHAR/VARCHAR, matching `outputTypes`, and add a mixed CHAR-plus-UDT 
columnar-input regression.



##########
python/pyspark/sql/connect/udtf.py:
##########
@@ -167,9 +172,21 @@ def __init__(
         self.evalType = evalType
         self.deterministic = deterministic
 
+    def _check_return_type(self) -> None:
+        if self.returnType is None:
+            return
+        return_type = (
+            _parse_datatype_string(self.returnType.data_type_string)

Review Comment:
   **Non-blocking (P2):** This parse happens before checking whether 
`_check_arrow_udtf_return_type` applies. In Connect, even an ordinary 
SQL_TABLE_UDF with a DDL schema now sends a synchronous `ddl_parse` Analyze 
request on every invocation, then discards the parsed type; it also parses 
through `SparkSession.active()` rather than the session used for registration. 
Please short-circuit non-Arrow eval types before parsing, and make necessary 
Arrow validation session-bound and reusable instead of repeating the RPC on 
every plan construction.
   
   **Recommended change:** Gate parsing on the two Arrow UDTF eval types, then 
bind and cache any required validation against the operation's owning Connect 
session.
   
   **Why this works:** Return before DDL parsing for non-Arrow modes; for Arrow 
modes, retain the parsed type or validated result on the UDTF instance with the 
session context that performed the analysis.
   
   **Scope:** Connect UDTF return-type validation, invocation/registration 
session ownership, and RPC-count regression coverage.
   
   **Compatibility:** Ordinary UDTFs recover lazy local plan construction; 
Arrow UDTFs retain the intended early recursive rejection without cross-session 
parsing.
   
   **Risks:** Caching a parse without session identity could reuse 
catalog-dependent resolution across sessions. Gating too broadly could skip 
required validation for SQL_ARROW_TABLE_UDF.
   
   **Constraints:** Do not add a server request to non-Arrow UDTF plan 
construction. Do not use an unrelated globally active session for registration 
validation.
   
   **Success:** Repeated ordinary UDTF calls perform no validation RPC, while 
Arrow validation runs through the correct session no more often than necessary.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowConverters.scala:
##########
@@ -577,7 +592,7 @@ private[sql] object ArrowConverters extends Logging {
         TaskContext.get())
 
       // Project/copy it. Otherwise, the Arrow column vectors will be closed 
and released out.
-      val proj = UnsafeProjection.create(attrs, attrs)
+      val proj = UnsafeProjection.create(checkedAttrs, attrs)

Review Comment:
   **Non-blocking (P2):** In the local driver branch, `fromBatchIterator` can 
open an allocator and VectorSchemaRoot before this projection is evaluated, and 
there is no TaskContext listener. If the new CHAR/VARCHAR projection throws 
EXCEED_LIMIT_LENGTH, `toArray` abandons the iterator without exhausting its 
close-on-end path, so repeated expected failures retain native Arrow resources. 
Please materialize under deterministic cleanup that closes the iterator on 
projection or deserialization failure as well as on normal exhaustion.
   
   **Recommended change:** Give the local Arrow iterator an explicit close 
boundary and materialize it under try/finally-style resource management.
   
   **Why this works:** Expose or wrap InternalRowIterator cleanup so the driver 
branch closes its allocator and vector root after success and after any 
projection or deserialization exception.
   
   **Scope:** Arrow batch iterator lifecycle and the local explicit-schema 
DataFrame conversion branch, with an exceptional cleanup regression test.
   
   **Compatibility:** Successful local and RDD conversion results are 
unchanged; exceptional local conversion releases resources deterministically.
   
   **Risks:** Closing before copied rows are fully materialized would 
invalidate retained Arrow-backed values. Duplicated close calls must remain 
safe on normal iterator exhaustion.
   
   **Constraints:** Keep the RDD TaskContext cleanup path intact. Copy all 
retained rows before closing the Arrow vectors.
   
   **Success:** Both successful exhaustion and any validation/deserialization 
failure close all driver-side Arrow resources exactly once.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvaluatePython.scala:
##########
@@ -146,10 +147,19 @@ object EvaluatePython {
    * Make a converter that converts `obj` to the type specified by the data 
type, or returns
    * null if the type of obj is unexpected. Because Python doesn't enforce the 
type.
    */
-  def makeFromJava(dataType: DataType): Any => Any =
-    
TypeApiOps(dataType).flatMap(_.makeFromJava).getOrElse(makeFromJavaDefault(dataType))
+  def makeFromJava(dataType: DataType): Any => Any = {
+    val applyCharVarcharChecks =
+      CharVarcharUtils.shouldApplyWriteSideLengthCheck(SQLConf.get)

Review Comment:
   **Blocking (P1):** This ambient SQLConf read is also used by 
`PythonSQLUtils.toJVMRow` on the TransformWithState state-server thread. The 
runner captures the query configuration, but the separate server thread never 
installs it, so `SQLConf.get` falls back to defaults. A legacy-mode query 
updating `VARCHAR(3)` state with `abcd` can therefore raise EXCEED_LIMIT_LENGTH 
even though that mode requires plain STRING behavior. Please pass an explicit 
derived policy into conversion, or install the runner's captured SQLConf around 
the state-server loop, and exercise legacy CHAR/VARCHAR state updates.
   
   **Recommended change:** Bind state conversion to the runner's query 
configuration by passing an explicit write-check policy through the 
state-server conversion boundary.
   
   **Why this works:** Derive the policy while the query SQLConf is available 
and supply it to toJVMRow/makeFromJava, avoiding an ambient ThreadLocal read on 
the helper thread.
   
   **Scope:** TransformWithState Python runner/state-server conversion plumbing 
and focused legacy-mode state tests.
   
   **Compatibility:** Standard semantics retain padding and overflow checks; 
legacy charVarcharAsString queries retain unbounded STRING behavior independent 
of thread placement.
   
   **Risks:** Installing a broad SQLConf around a long-lived server loop could 
unintentionally affect unrelated configuration reads. A policy passed to only 
some state operations could leave value, list, and map state inconsistent.
   
   **Constraints:** Apply one query-bound policy consistently to every state 
schema conversion. Do not rely on the helper thread inheriting SQLConf.
   
   **Success:** Value, list, and map state conversions honor the query's 
standard or legacy CHAR/VARCHAR semantics on every state-server thread.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ColumnarArrowEvalPythonEvaluatorFactory.scala:
##########
@@ -79,6 +81,22 @@ private[python] class 
ColumnarArrowEvalPythonEvaluatorFactory(
     sessionUUID: Option[String])
   extends PartitionEvaluatorFactory[ColumnarBatch, ColumnarBatch] {
 
+  private val applyCharVarcharChecks =
+    CharVarcharUtils.shouldApplyWriteSideLengthCheck(SQLConf.get)

Review Comment:
   **Blocking (P1):** The standard-semantics config is PERSISTED because it 
determines how a view body resolves, but this factory recomputes the 
enforcement decision from the caller's session at physical execution. A view 
created with standard semantics can therefore return over-length VARCHAR or 
unpadded CHAR when queried from a legacy-mode session. Please carry the 
resolved enforcement decision into the logical/physical Python node instead of 
rereading ambient SQLConf, and add a view test that flips the caller 
configuration before execution.
   
   **Recommended change:** Carry a plan-bound CHAR/VARCHAR enforcement decision 
from resolution into Python physical execution.
   
   **Why this works:** Record the persisted semantic mode, or the derived 
write-check boolean, on the resolved Python operator and pass it into evaluator 
construction instead of consulting SQLConf.get there.
   
   **Scope:** Python logical/physical operator configuration plumbing, columnar 
evaluator construction, and a persisted-view regression test.
   
   **Compatibility:** Direct queries continue to use their own resolved mode, 
while views keep the semantics captured when their bodies were resolved as 
required by the binding policy.
   
   **Risks:** Binding only the columnar evaluator could leave row-based Python 
execution inconsistent. Serializing a session object instead of a small 
semantic value would broaden plan state unnecessarily.
   
   **Constraints:** Bind a minimal semantic value rather than ambient session 
state. Keep all Python evaluator variants consistent with the resolved plan.
   
   **Success:** Changing the querying session's CHAR/VARCHAR settings cannot 
change the results or validation behavior of an already resolved view body.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvalPythonEvaluatorFactory.scala:
##########
@@ -36,6 +38,16 @@ abstract class EvalPythonEvaluatorFactory(
     output: Seq[Attribute])
   extends PartitionEvaluatorFactory[InternalRow, InternalRow] {
 
+  private val applyCharVarcharChecks =
+    CharVarcharUtils.shouldApplyWriteSideLengthCheck(SQLConf.get)
+  private val checkedOutput = if (applyCharVarcharChecks) {
+    childOutput ++ output.drop(childOutput.length).map { attr =>
+      CharVarcharUtils.stringLengthCheck(attr, attr.dataType)

Review Comment:
   **Non-blocking (P2):** This common projection also wraps 
BatchEvalPythonEvaluatorFactory, whose unpickling path already calls 
`EvaluatePython.makeFromJava(resultType)`. The new converter recursively 
validates CHAR/VARCHAR in arrays, maps, structs, and UDT storage types, so a 
batched UDF returning ARRAY<VARCHAR> now traverses and rebuilds the complete 
nested value twice. Please let concrete evaluators declare whether 
deserialization already enforces assignment semantics, and apply this 
projection only to paths that still need it.
   
   **Recommended change:** Move ownership of the post-result check to 
evaluator-specific capability rather than applying it unconditionally in the 
common base.
   
   **Why this works:** Have concrete factories opt in or out of checkedOutput 
based on whether their deserializer already performs recursive CHAR/VARCHAR 
assignment validation.
   
   **Scope:** Python evaluator factory capability plumbing and a nested 
batched-UDF regression that confirms one enforcement layer.
   
   **Compatibility:** All evaluator modes retain identical padding and overflow 
behavior; only redundant traversal on already-validating batch paths is removed.
   
   **Risks:** An incorrect opt-out could remove the only enforcement layer from 
an Arrow path. A coarse factory flag could miss mixed conversion paths.
   
   **Constraints:** Document which concrete conversion boundary owns assignment 
enforcement. Keep exactly one recursive check on every supported result path.
   
   **Success:** Nested results are validated once on batch and Arrow paths, 
with unchanged CHAR padding and VARCHAR overflow failures.



##########
python/pyspark/sql/udf.py:
##########
@@ -314,9 +317,24 @@ def _conf_is_true(key: str, default: Optional[str] = None) 
-> bool:
 
     @staticmethod
     def _check_return_type(returnType: DataType, evalType: int) -> None:
+        char_varchar_supported_eval_types = (
+            PythonEvalType.SQL_ARROW_BATCHED_UDF,
+            PythonEvalType.SQL_SCALAR_PANDAS_UDF,
+            PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,

Review Comment:
   **Non-blocking (P2):** The tuple now promises CHAR/VARCHAR support for 
SCALAR_ITER pandas UDFs and both scalar Arrow UDF modes, but the new 
integration cases execute only SQL_ARROW_BATCHED_UDF and default 
SQL_SCALAR_PANDAS_UDF. Deleting one of these three entries or breaking its 
distinct worker serialization would leave the suite green. Please add 
owning-suite cases for `PandasUDFType.SCALAR_ITER`, `ArrowUDFType.SCALAR`, and 
`ArrowUDFType.SCALAR_ITER`, asserting padded CHAR output and 
EXCEED_LIMIT_LENGTH for an overlong VARCHAR result in each mode.



-- 
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]

Reply via email to