szehon-ho commented on code in PR #57644:
URL: https://github.com/apache/spark/pull/57644#discussion_r3798212968


##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala:
##########
@@ -317,9 +318,23 @@ object DatasetManager extends Logging {
     val (catalog, identifier) =
       PipelinesCatalogUtils.resolveTableCatalog(context.spark, 
table.identifier)
 
-    val outputSchema = table.specifiedSchema.getOrElse(
-      inferredSchemas(table.identifier).asNullable
-    )
+    val outputSchema = table.specifiedSchema match {
+      case Some(ss) =>
+        // The user schema describes the logical table; the engine owns the 
reserved AUTO CDC
+        // metadata column(s). Drop whatever reserved column(s) the user 
declared and append the
+        // engine-owned shape from the inferred schema, so the created table 
always has exactly the
+        // reserved column(s) the AUTO CDC MERGE writes, even if the user 
declared one with a
+        // different type or nullability. Matching goes through the flow's 
effective resolver (the
+        // same one the rest of AUTO CDC uses, which honors a case-sensitivity 
conf set on the flow,
+        // not just the session).
+        val resolver = SchemaInferenceUtils.resolverFor(
+          effectiveCaseSensitivityFor(resolvedDataflowGraph, table.identifier, 
context))
+        StructType(
+          AutoCdcReservedNames.stripReservedFields(ss, resolver).fields ++

Review Comment:
   This runs for every table with a declared schema, with no check that an AUTO 
CDC flow writes to it, and the same is true of the strip on both sides in 
`GraphValidations`. Nothing outside `AutoCdcMergeFlow` rejects 
reserved-prefixed names -- `requireReservedPrefixAbsentInSourceColumns` lives 
on that flow and checks the source DataFrame, not user-declared table schemas.
   
   So for an ordinary table whose user declares a column named 
`__spark_autocdc_x`, `stripReservedFields(ss, ...)` drops it and the append 
cannot put it back: `DataflowGraph.inferSchemas` passes `userSpecifiedSchema = 
None`, so `inferredSchemas` here is purely flow-derived and never contains a 
user-only column. Previously `outputSchema` was `ss` verbatim and the column 
was created; now it is silently dropped, and on an MV or full refresh 
`diffSchemas` turns that into a `deleteColumn` against an existing table. On 
the validation side, a declared schema that omits a reserved-prefixed column a 
non-CDC flow actually produces now passes where it used to fail.
   
   `materializeTable` already computes `autoCdcAuxTableSpecOpt` and 
`GraphValidations` now has `flows` in scope, so gating both on the table 
actually having an AUTO CDC flow looks cheap and keeps the relaxation inside 
the feature it was written for.
   
   Separately, worth saying in the comment that `inferredSchemas` here is 
flow-only, unlike the `inferredSchema` in `GraphValidations`, which merges the 
declaration in. The correctness of "append the engine-owned shape" rests on 
that difference and the two are hard to tell apart at the call site.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala:
##########
@@ -317,9 +318,23 @@ object DatasetManager extends Logging {
     val (catalog, identifier) =
       PipelinesCatalogUtils.resolveTableCatalog(context.spark, 
table.identifier)
 
-    val outputSchema = table.specifiedSchema.getOrElse(
-      inferredSchemas(table.identifier).asNullable
-    )
+    val outputSchema = table.specifiedSchema match {

Review Comment:
   Before this change, `validateUserSpecifiedSchemas` required `inferredSchema 
== ss`, so a declared schema was always exactly the schema the table got 
materialized with. Parts of the planning path rely on that; in particular 
`VirtualTableInput.load` uses the declaration as-is and never consults the 
materialized table:
   
   ```scala
   val deducedSchema = specifiedSchema match {
     // If the user specified a schema, use it directly.
     case Some(ss) => ss
   ```
   
   With `outputSchema` now differing from `ss`, a same-graph downstream dataset 
resolves against the declared columns at analysis time but reads the real table 
at execution time. `reanalyzeFlow` builds a subgraph whose `tables` is only the 
destination, and `dfsInternal(..., stopAtMaterializationPoints = true)` skips 
the upstream table, so it is not in `context.allInputs` and 
`GraphIdentifierManager` classifies it as an `ExternalDatasetIdentifier` -- a 
plain read of the four-column catalog table.
   
   Concretely, with `CREATE STREAMING TABLE target (id INT, name STRING, 
version BIGINT)` fed by an AUTO CDC flow, plus `CREATE MATERIALIZED VIEW 
enriched AS SELECT * FROM target`: `target` materializes with four columns, 
`enriched` is planned and materialized with three, and at execution `SELECT *` 
yields four columns written into the three-column `enriched`.
   
   I traced this statically rather than running it, so I can't say whether it 
surfaces as an "extra fields" `AnalysisException` or as silent evolution on 
`enriched`. Either way I think it needs a test with a downstream `SELECT *` 
over an AUTO CDC target that has a data-only declaration. The cheapest fix I 
can see is to factor "the schema this table materializes with" into one helper 
and have both this method and `VirtualTableInput.load` call it.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala:
##########
@@ -317,9 +318,23 @@ object DatasetManager extends Logging {
     val (catalog, identifier) =
       PipelinesCatalogUtils.resolveTableCatalog(context.spark, 
table.identifier)
 
-    val outputSchema = table.specifiedSchema.getOrElse(
-      inferredSchemas(table.identifier).asNullable
-    )
+    val outputSchema = table.specifiedSchema match {
+      case Some(ss) =>
+        // The user schema describes the logical table; the engine owns the 
reserved AUTO CDC
+        // metadata column(s). Drop whatever reserved column(s) the user 
declared and append the
+        // engine-owned shape from the inferred schema, so the created table 
always has exactly the
+        // reserved column(s) the AUTO CDC MERGE writes, even if the user 
declared one with a
+        // different type or nullability. Matching goes through the flow's 
effective resolver (the
+        // same one the rest of AUTO CDC uses, which honors a case-sensitivity 
conf set on the flow,
+        // not just the session).
+        val resolver = SchemaInferenceUtils.resolverFor(
+          effectiveCaseSensitivityFor(resolvedDataflowGraph, table.identifier, 
context))
+        StructType(
+          AutoCdcReservedNames.stripReservedFields(ss, resolver).fields ++
+            
AutoCdcReservedNames.reservedFields(inferredSchemas(table.identifier), 
resolver))
+      case None =>
+        inferredSchemas(table.identifier).asNullable

Review Comment:
   The `None` branch applies `.asNullable`, but the appended reserved field in 
the `Some` branch keeps the engine's `nullable = false` 
(`AutoCdcMergeFlow.schema` sets that for both SCD types). So the same pipeline 
creates a NOT NULL metadata column when the user declares a schema and a 
nullable one when they don't, and adding or removing a declaration between runs 
shows up as an `updateColumnNullability` from `diffSchemas`. Applying 
`.asNullable` to the appended fields would make the two branches agree.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala:
##########
@@ -317,9 +318,23 @@ object DatasetManager extends Logging {
     val (catalog, identifier) =
       PipelinesCatalogUtils.resolveTableCatalog(context.spark, 
table.identifier)
 
-    val outputSchema = table.specifiedSchema.getOrElse(
-      inferredSchemas(table.identifier).asNullable
-    )
+    val outputSchema = table.specifiedSchema match {
+      case Some(ss) =>
+        // The user schema describes the logical table; the engine owns the 
reserved AUTO CDC
+        // metadata column(s). Drop whatever reserved column(s) the user 
declared and append the
+        // engine-owned shape from the inferred schema, so the created table 
always has exactly the
+        // reserved column(s) the AUTO CDC MERGE writes, even if the user 
declared one with a
+        // different type or nullability. Matching goes through the flow's 
effective resolver (the
+        // same one the rest of AUTO CDC uses, which honors a case-sensitivity 
conf set on the flow,
+        // not just the session).
+        val resolver = SchemaInferenceUtils.resolverFor(
+          effectiveCaseSensitivityFor(resolvedDataflowGraph, table.identifier, 
context))

Review Comment:
   `effectiveCaseSensitivityFor` is already computed a few lines below into 
`effectiveCaseSensitive` / `effectiveResolver`. Each call walks the flows and 
can throw `conflictingFlowConfigurationError`, so this does the work twice and 
reads as though they might be two different resolvers. Hoisting the existing 
pair above `outputSchema` and reusing it would cover both.



##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/GraphValidations.scala:
##########
@@ -258,20 +259,30 @@ trait GraphValidations extends Logging {
     // table's); for a named flow (e.g. `CREATE FLOW <name> AS AUTO CDC INTO 
<target>`) they
     // differ, and keying on the flow identifier would silently skip 
validation.
     flowsTo.keys.flatMap(table.get).foreach { t: TableElement =>
+      val flows = flowsTo(t.identifier).map(f => resolvedFlow(f.identifier))
       // The output inferred schema of a table is the declared schema merged 
with the
       // schema of all incoming flows. This must be equivalent to the declared 
schema.
       val inferredSchema = SchemaInferenceUtils
         .inferSchemaFromFlows(
           tableIdentifier = t.identifier,
-          flowsTo(t.identifier).map(f => resolvedFlow(f.identifier)),
+          flows,
           userSpecifiedSchema = t.specifiedSchema,
           sessionCaseSensitive = sessionCaseSensitive
         )
+      // Match reserved columns with the flow's effective case sensitivity, 
not just the session:
+      // a case-sensitivity conf set on the flow overrides the session, the 
same way the rest of
+      // AUTO CDC derives its resolver.
+      val resolver = SchemaInferenceUtils.resolverFor(
+        SchemaInferenceUtils.effectiveCaseSensitivity(t.identifier, flows, 
sessionCaseSensitive))
 
       t.specifiedSchema.foreach { ss =>
-        // Check the inferred schema matches the specified schema. Used to 
catch errors where the
-        // inferred user-facing schema has columns that are not in the 
specified one.
-        if (inferredSchema != ss) {
+        // Check the specified schema matches the inferred schema once the 
engine-owned reserved
+        // AUTO CDC metadata column(s) are set aside on both sides. The user 
may omit them (the
+        // engine appends them at materialization) or declare them; comparing 
both schemas with the
+        // reserved columns removed accepts either while still catching a 
genuine mismatch in the
+        // remaining columns, and stays correct if more than one reserved 
column is ever added.
+        if (AutoCdcReservedNames.stripReservedFields(inferredSchema, resolver) 
!=
+            AutoCdcReservedNames.stripReservedFields(ss, resolver)) {
           val datasetType = GraphElementTypeUtils
             .getDatasetTypeForMaterializedViewOrStreamingTable(
               flowsTo(t.identifier).map(f => resolvedFlow(f.identifier))

Review Comment:
   Nit: this can use the `flows` val hoisted at the top of the loop now.



##########
sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/AutoCdcReservedColumnMaterializationSuite.scala:
##########
@@ -0,0 +1,136 @@
+/*
+ * 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.pipelines.graph
+
+import java.util.Locale
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
+import org.apache.spark.sql.functions
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.pipelines.autocdc.{AutoCdcReservedNames, 
Scd1BatchProcessor}
+import org.apache.spark.sql.pipelines.utils.{ExecutionTest, 
TestGraphRegistrationContext}
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, LongType, StringType, 
StructType}
+
+/**
+ * Materialization-level tests for AUTO CDC's engine-owned reserved metadata 
column (SPARK-58118).
+ *
+ * The reserved `__spark_autocdc_metadata` column is engine-owned: a 
user-declared schema may omit
+ * it, and materialization appends the engine-owned shape so the created table 
matches what the
+ * AUTO CDC MERGE writes at runtime. Reserved-column matching goes through the 
flow's effective
+ * case sensitivity -- a pipeline-level `SET spark.sql.caseSensitive` can 
differ from the session --
+ * so these tests inspect the created table's schema rather than only 
validation.
+ */
+class AutoCdcReservedColumnMaterializationSuite

Review Comment:
   Both cases here cover a target created from scratch and inspected directly. 
Could we also add one where another dataset in the same graph does `SELECT *` 
from the target? See the comment on `DatasetManager.materializeTable` -- that 
is the case where the declared schema and the materialized schema differing 
becomes observable.



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