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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala:
##########
@@ -200,6 +201,8 @@ object ExtractPythonUDFs extends Rule[LogicalPlan] with 
Logging {
       case Seq(child: PythonUDF) =>
         correctEvalType(e, pythonUDFArrowFallbackOnUDT) ==
           correctEvalType(child, pythonUDFArrowFallbackOnUDT) &&
+          !(CharVarcharUtils.shouldApplyWriteSideLengthCheck(conf) &&

Review Comment:
   **Blocking (P1):** This decision re-reads the querying session's SQLConf 
even though the child UDF now carries its resolved policy. If a view is 
resolved under standard semantics and queried from a legacy session, this can 
fuse away the child's JVM boundary, so the outer UDF sees an unpadded CHAR or 
an over-length VARCHAR avoids its error. Please gate on 
`child.applyCharVarcharChecks && hasCharVarchar(child.dataType)` and add a 
construction-versus-execution config-transition regression.
   
   **Recommended change:** Make nested-UDF fusion depend on the resolved child 
expression's captured policy rather than optimizer-time SQLConf.
   
   **Why this works:** Replace the ambient write-side policy read with 
child.applyCharVarcharChecks while retaining the constrained-type predicate, so 
checked intermediates keep a JVM conversion boundary.
   
   **Scope:** ExtractPythonUDFs nested-chain extraction and a persisted or 
lazy-plan configuration-transition test.
   
   **Compatibility:** Preserve current fusion for unchecked children and 
unconstrained result types; only prevent fusion where the resolved child 
already requires assignment checks.
   
   **Risks:** Blocking fusion adds a Python evaluation boundary for constrained 
checked intermediates. A regression test that does not separate resolution from 
execution would miss the lifecycle defect.
   
   **Constraints:** Do not re-resolve policy from the current SQLConf. Keep the 
change local to constrained child results whose captured flag is true.
   
   **Success:** The same resolved nested-UDF plan returns identical checked 
results when later queried under an opposite CHAR/VARCHAR session policy.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala:
##########
@@ -337,7 +337,8 @@ case class PythonUDF(
     // single lambda, and one more for each enclosing lambda when the UDF is 
lifted out of a nested
     // lambda (e.g. `transform(arr, i -> transform(i, x -> f(x)))` lifts `f` 
to depth 2). Ignored
     // for every non-element-wise eval type, where it stays at its default of 
1.
-    elementwiseNestingDepth: Int = 1)
+    elementwiseNestingDepth: Int = 1,
+    applyCharVarcharChecks: Boolean = false)

Review Comment:
   **Blocking (P1):** The lambda rewrite builds a replacement `PythonUDF` but 
does not copy this newly captured flag, so its default becomes false. A 
CHAR/VARCHAR UDF inside `transform` can then return an unpadded CHAR or accept 
an over-length VARCHAR. Please pass `applyCharVarcharChecks = 
udf.applyCharVarcharChecks` when constructing the lifted UDF and add 
higher-order-function coverage for both outcomes.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -103,6 +116,71 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     }
   }
 
+  test("Arrow-backed source: CHAR/VARCHAR output checks") {
+    assume(shouldTestPandasUDFs)
+    withSQLConf(
+        SQLConf.ARROW_PYSPARK_EXECUTION_ENABLED.key -> "true",
+        SQLConf.ARROW_PYSPARK_UDF_COLUMNAR_INPUT_ENABLED.key -> "true",
+        SQLConf.CHAR_VARCHAR_STANDARD_SEMANTICS.key -> "true") {
+      val charUDF = TestTypedScalarPandasUDF(
+        name = "arrow_char_udf", returnType = CharType(4))
+      val varcharUDF = TestTypedScalarPandasUDF(
+        name = "arrow_varchar_udf", returnType = VarcharType(3))
+      registerTestUDF(charUDF, spark)
+      registerTestUDF(varcharUDF, spark)
+
+      val df = readArrowSource(numRows = 10)
+      val padded = df.selectExpr(
+        "id", "name", "value", "data",
+        "arrow_char_udf(id) as udf_id")
+      val arrowExec = collectNodes[ArrowEvalPythonExec](
+        padded.queryExecution.executedPlan).head
+      assert(arrowExec.child.supportsColumnar,
+        "ArrowEvalPythonExec should retain its Arrow-backed columnar child")
+      assert(padded.select("udf_id").collect().map(_.getString(0)).toSeq ===

Review Comment:
   **Non-blocking (P2):** This inspects `padded` but collects 
`padded.select("udf_id")`, which is a separately optimized query. The added 
projection can prune the Arrow source and make the collected query fall back to 
row input, so the test can pass without exercising the asserted columnar path. 
Please collect `padded` itself, read the UDF field at its original ordinal, and 
make the same change in the legacy case.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/EvaluatePython.scala:
##########
@@ -227,8 +277,8 @@ object EvaluatePython {
       }
 
     case MapType(keyType, valueType, _) =>
-      val keyFromJava = makeFromJava(keyType)
-      val valueFromJava = makeFromJava(valueType)
+      val keyFromJava = makeFromJava(keyType, applyCharVarcharChecks)

Review Comment:
   **Blocking (P1):** Key conversion can now coalesce distinct Python keys: for 
`MapType(CharType(2), ...)`, both `"a"` and `"a "` become the same Catalyst 
key. Passing those entries directly to `ArrayBasedMapData` violates its 
documented no-duplicates precondition. Please build through the duplicate-aware 
map builder, or otherwise detect post-normalization collisions and honor 
`spark.sql.mapKeyDedupPolicy`.
   
   **Recommended change:** Route converted map entries through Spark's 
duplicate-aware map construction policy after key normalization.
   
   **Why this works:** Normalize each key first, then use ArrayBasedMapBuilder 
or equivalent collision detection so equal normalized keys follow 
spark.sql.mapKeyDedupPolicy.
   
   **Scope:** Python-to-Catalyst map conversion for constrained key types and 
collision regression tests.
   
   **Compatibility:** Preserve behavior for maps whose normalized keys remain 
unique; make newly created collisions follow the same policy as other Catalyst 
map constructors.
   
   **Risks:** Applying deduplication before CHAR normalization would miss the 
collision. Ignoring the configured LAST_WIN policy or exception behavior would 
diverge from Spark SQL map semantics.
   
   **Constraints:** Do not admit duplicate keys into ArrayBasedMapData. Apply 
the configured duplicate-key policy to the normalized Catalyst keys.
   
   **Success:** Keys such as `a` and `a ` either raise the configured 
duplicate-key error or deterministically deduplicate under LAST_WIN, and all 
unique maps remain unchanged.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExec.scala:
##########
@@ -106,7 +106,13 @@ class BatchEvalPythonEvaluatorFactory(
       StructType(udfs.map(u => StructField("", u.dataType, u.nullable)))
     }
 
-    val fromJava = EvaluatePython.makeFromJava(resultType)
+    val fromJava = if (udfs.length == 1) {
+      EvaluatePython.makeFromJava(resultType, udfs.head.applyCharVarcharChecks)
+    } else {
+      EvaluatePython.makeFromJava(

Review Comment:
   **Non-blocking (P2):** This sequence is consumed positionally, but current 
multi-UDF coverage makes every flag false. Please construct two independent 
expressions under opposite policies, evaluate them together in one 
`BatchEvalPythonExec`, and assert that the checked CHAR field is padded while 
the unchecked over-length VARCHAR field is preserved at the matching ordinal.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowConverters.scala:
##########
@@ -557,13 +574,19 @@ private[sql] object ArrowConverters extends Logging {
       val rdd = session.sparkContext
         .parallelize(batchesInDriver.toImmutableArraySeq, 
batchesInDriver.length)
         .mapPartitions { batchesInExecutors =>
-          ArrowConverters.fromBatchIterator(
+          val rows = ArrowConverters.fromBatchIterator(
             batchesInExecutors,
             schema,
             timeZoneId,
             errorOnDuplicatedFieldNames,
             largeVarTypes,
             TaskContext.get())
+          if (applyCharVarcharChecks) {

Review Comment:
   **Non-blocking (P2):** This is a separately implemented executor-side 
projection, but the standard-semantics tests all stay on the local branch; the 
only test forcing threshold 0 enables legacy mode, where this block is skipped. 
Please force the RDD branch under standard semantics and assert both CHAR 
padding and an `EXCEED_LIMIT_LENGTH` failure during materialization.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/arrow/ArrowConverters.scala:
##########
@@ -548,6 +557,14 @@ private[sql] object ArrowConverters extends Logging {
       errorOnDuplicatedFieldNames: Boolean,
       largeVarTypes: Boolean): DataFrame = {
     val attrs = toAttributes(schema)
+    val applyCharVarcharChecks =
+      CharVarcharUtils.hasCharVarchar(schema) &&
+        
CharVarcharUtils.shouldApplyWriteSideLengthCheck(session.sessionState.conf)
+    val checkedAttrs = if (applyCharVarcharChecks) {

Review Comment:
   **Blocking (P1):** In legacy-as-string mode this flag is false, but `attrs` 
and the schema passed to both relation branches still contain CHAR/VARCHAR 
while first-class types are disabled. `CheckAnalysis` rejects those leaf 
outputs before the intended unchecked values can be read. Please normalize the 
relation schema and attributes to STRING under the existing legacy policy, 
retaining the declared logical schema only where standard-mode projection needs 
it.
   
   **Recommended change:** Separate the declared schema used for standard 
assignment checks from the policy-normalized schema exposed by Arrow relations.
   
   **Why this works:** Under legacy-as-string mode, derive relation attributes 
and the LogicalRDD or LocalRelation schema from the existing 
CHAR/VARCHAR-to-STRING normalization while keeping the original schema only for 
Arrow decoding or standard checks where required.
   
   **Scope:** ArrowConverters.toDataFrame relation construction and any 
Python-side schema caching that exposes the unnormalized logical schema.
   
   **Compatibility:** Restore the established legacy contract that constrained 
strings appear as STRING and remain unchecked; preserve first-class logical 
types under standard semantics.
   
   **Risks:** Normalizing the decoding schema too early could lose information 
needed for standard-mode checks. Using different attribute sequences for 
projection and relation construction can cause expression-ID or type mismatches.
   
   **Constraints:** Both the RDD and local relation branches must expose the 
same policy-normalized schema. Do not normalize away CHAR/VARCHAR in standard 
semantics.
   
   **Success:** Both Arrow relation branches analyze and return unchecked 
STRING values in legacy mode, while standard mode still pads CHAR and rejects 
over-length VARCHAR.



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