spark-test commented on code in PR #57644:
URL: https://github.com/apache/spark/pull/57644#discussion_r4356765634
##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala:
##########
@@ -317,9 +318,36 @@ object DatasetManager extends Logging {
val (catalog, identifier) =
PipelinesCatalogUtils.resolveTableCatalog(context.spark,
table.identifier)
- val outputSchema = table.specifiedSchema.getOrElse(
- inferredSchemas(table.identifier).asNullable
- )
+ // A table is an AUTO CDC target iff it has an AUTO CDC auxiliary table
spec. Compute the
+ // effective (flow-aware) resolver once here and reuse it for both the
output schema below and
+ // the drift checks further down; each `effectiveCaseSensitivityFor` call
walks the flows and
+ // can throw on a conflicting per-flow conf, so computing it a single time
also avoids doing
+ // that work (and that potential throw) twice.
+ val autoCdcAuxTableSpecOpt = auxiliaryTableSpecOpt.collect {
+ case autoCdcSpec: AutoCdcAuxiliaryTableSpec => autoCdcSpec
+ }
+ val effectiveCaseSensitive =
+ effectiveCaseSensitivityFor(resolvedDataflowGraph, table.identifier,
context)
+ val effectiveResolver =
SchemaInferenceUtils.resolverFor(effectiveCaseSensitive)
+
+ val outputSchema = table.specifiedSchema match {
+ case Some(ss) if autoCdcAuxTableSpecOpt.isDefined =>
+ // The user schema describes the logical AUTO CDC target; the engine
owns the reserved
+ // metadata column(s). Drop whatever reserved column(s) the user
declared and append the
Review Comment:
**Nit (P3):** This comment and the matching one in `VirtualTableInput.load`
(elements.scala:187-188) say the declared reserved column is dropped and the
engine-owned shape appended, so the table "always has exactly the reserved
column(s) the AUTO CDC MERGE writes". `appendEngineOwnedReservedFields` does
something different: it rebuilds the declaration in place. A declared reserved
column keeps its declared outer and nested spelling and position and takes only
the engine type and nullability, and a prefixed declared field with no engine
counterpart is kept. The upgrade-path tests and the casing-drift check depend
on that spelling preservation. A follow-up that trusted this comment and
canonicalized the name would reintroduce the mismatch this PR fixes.
Separately, AutoCdcReservedColumnMaterializationSuite.scala:143 calls the
engine column `nullable=false`, but the helper makes engine-owned reserved
fields nullable, which the suite's own nullability test asserts.
**Verification:**
- **Inspection:** The comments on the declared AUTO CDC branches in
materialization and in the analysis-time read path match the helper's in-place,
spelling-preserving reconciliation, and the test comment matches the nullable
engine column.
##########
common/utils/src/main/resources/error/error-conditions.json:
##########
@@ -294,6 +294,11 @@
"One or more AutoCDC flows writing to the target use key columns
<expectedKeySchema>, which are inconsistent with the keys recorded for it
(recorded <recordedKeySchema>). AutoCDC does not support changing key columns
or their types across incremental pipeline runs. Correct the conflicting
flow(s) or perform a full refresh of the target table."
]
},
+ "RESERVED_METADATA_COLUMN_CASING_DRIFT" : {
+ "message" : [
+ "The target table already has the reserved AutoCDC metadata column
as `<existingColumnName>`, but the current table definition resolves it to
`<resolvedColumnName>`. AutoCDC does not support changing the casing of the
reserved metadata column across incremental pipeline runs: the target keeps its
existing spelling while downstream consumers are planned against the new one,
so a case-sensitive read of the target could fail to match them. Declare the
reserved column with its existing name `<existingColumnName>`, or perform a
full refresh of the target table."
Review Comment:
**Nit (P3):** `reservedFieldCasingDrifts` also reports nested drift, as
dot-qualified paths: the nested rejection test expects `existingColumnName =
__spark_autocdc_metadata.DELETESEQUENCE`. With this template the message then
says the target "already has the reserved AutoCDC metadata column as
`__spark_autocdc_metadata.DELETESEQUENCE`" and asks the user to "Declare the
reserved column with its existing name
`__spark_autocdc_metadata.DELETESEQUENCE`". That reads as one top-level column
with a dot in its name. The actual remedy is to declare the metadata column
with its nested field spelled `DELETESEQUENCE`, or to perform a full refresh.
When the nested name comes from a struct-typed SEQUENCE BY expression, the user
never declared that field at all. Crossing an array element adds no path
segment, so array cases are even harder to map back to the declaration.
This extends an [existing
discussion](https://github.com/apache/spark/pull/57644#issuecomment-6087653115).
**Verification:**
- **Behavior:** The nested casing-drift rejection renders a message naming
the nested field path within the metadata column and a remedy that refers to
declaring the metadata column with that existing nested spelling or performing
a full refresh.
- **Compatibility:** The top-level casing-drift rejection still reports the
same error condition and identifies the existing and resolved column spellings.
##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/GraphValidations.scala:
##########
@@ -271,24 +265,40 @@ 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) {
- val datasetType = GraphElementTypeUtils
- .getDatasetTypeForMaterializedViewOrStreamingTable(
- flowsTo(t.identifier).map(f => resolvedFlow(f.identifier))
- )
+ // A declared schema must match the schema inferred from the incoming
flows. Only for an
+ // AUTO CDC target are the engine-owned reserved metadata column(s)
set aside on both sides
+ // first: the user may omit them (the engine appends them at
materialization) or declare
+ // them, and comparing 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. For any other table the reserved prefix
carries no special meaning,
+ // so the comparison is exact -- otherwise a reserved-prefixed column
a non-CDC flow
+ // produces but the declaration omits would be wrongly accepted (then
dropped later).
+ val schemasMatch = if
(flows.exists(GraphElementTypeUtils.isAutoCdcFlow)) {
+ AutoCdcReservedNames.stripReservedFields(inferredSchema, resolver) ==
Review Comment:
**Non-blocking (P2):** Because every `__spark_autocdc_`-prefixed field is
stripped from both sides, validation can't reject the two declaration shapes
that `AutoCdcReservedNames.appendEngineOwnedReservedFields` says are rejected
upstream:
- A reserved-prefixed column with no engine counterpart, such as a typo
`__spark_autocdc_metdata STRUCT<...>` on an otherwise data-only declaration.
Before this PR it failed validation because the real metadata column was
missing. Now both sides reduce to the data columns. The helper keeps the
misspelled column (`case None => df`), so the created target and the
analysis-time read path get an extra reserved-namespace column the AUTO CDC
MERGE never writes. If that column is declared NOT NULL, the MERGE fails at
runtime instead.
- Both `__spark_autocdc_metadata` and `__SPARK_AUTOCDC_METADATA` under a
case-insensitive flow. `StructType.merge` folds both onto the single flow
field, the stripped comparison passes, and the user gets the helper's
`SparkException.internalError`. `VirtualTableInput.load` calls the same helper
while downstream flows are analyzed (`FlowAnalysis` calls `input.load` before
`DataflowGraph.validate` runs), so this can surface as INTERNAL_ERROR during
graph resolution.
Neither the SQL registration path
(`StructType(cst.columns.map(_.toV1Column))`) nor the Scala registration API
checks declared columns for the reserved prefix or for case-insensitive
duplicates. The only prefix guard,
`requireReservedPrefixAbsentInSourceColumns`, checks the change-feed source.
Both shapes therefore reach the helper unrejected, and its comments that
justify keeping or throwing on them are not true today.
This extends an [existing
discussion](https://github.com/apache/spark/pull/57644#issuecomment-5483130351).
**Recommended change:** Make AUTO CDC declared-schema validation set aside
only the engine-owned reserved columns the flow produces. Reject a
reserved-prefixed declared column that has no engine-owned counterpart, and a
declaration with more than one column matching the same engine-owned reserved
column, with a user-facing error. Have the shared reconciliation helper raise
that same user-facing error instead of an internal error when it meets such a
declaration on the analysis-time read path.
**Why this works:** Derive the engine-owned reserved names from the
flow-only inferred schema (the same source materialization uses) under the
flow-effective resolver. In validation, strip only declared fields that match
one of those names. Report AUTOCDC_RESERVED_COLUMN_NAME_PREFIX_CONFLICT, or an
equivalent user-facing condition, for any remaining reserved-prefixed declared
field and for any second declared field matching the same engine-owned column.
Put this check in one AutoCdcReservedNames helper and also call it from
appendEngineOwnedReservedFields, so VirtualTableInput.load, which runs during
graph resolution before validation, reports the same error. Update the helper
comments that currently claim upstream rejection.
**Scope:** Tighten the AUTO CDC declared-schema check in graph validation
and in the shared reserved-column helper so unknown or ambiguous reserved
declarations fail with a user-facing error on every route, and make the helper
comments match.
**Compatibility:** Declarations that omit the reserved column or declare it
exactly once keep their current materialized and analysis-time schema,
including the preserved declared spelling.
**Risks:** A declaration that also lists the real metadata column alongside
an extra `__spark_autocdc_*` column was accepted before this PR and would now
fail validation. Treat this as an intentional tightening and mention it in the
PR description.
**Constraints:** Reserved-name matching must use the flow-effective case
sensitivity. Under a case-sensitive flow, a differently cased user column such
as `__SPARK_AUTOCDC_METADATA` remains an ordinary user column and must not be
rejected.
**Exclusions:**
- Validating the nested shape of a declared reserved column beyond the
existing merge-compatibility check, such as rejecting extra nested fields
inside the metadata struct, stays out of scope.
**Success:** A data-only AUTO CDC declaration that adds a reserved-prefixed
column with no engine counterpart fails validation with a user-facing
reserved-prefix error. A declaration that matches the engine metadata column
more than once under the flow's case sensitivity fails with the same
user-facing error, both when a downstream dataset is analyzed and at
validation, and never with INTERNAL_ERROR. Data-only declarations, declarations
that include the reserved column exactly once, and case-sensitive flows with a
distinct differently cased user column behave as they do today.
**Verification:**
- **Behavior:** A data-only declaration on an AUTO CDC target that adds an
unmatched reserved-prefixed column is rejected at validation with the
user-facing reserved-prefix error.
- **Regression:** A declaration containing both case variants of the
metadata column under a case-insensitive flow, with a same-graph downstream
reader of the target, fails with the user-facing error rather than
INTERNAL_ERROR.
- **Compatibility:** The existing outcomes still hold: data-only and full
declarations are accepted, the wrong-type metadata declaration is rejected, the
case-sensitive distinct-column case is kept, and non-AUTO CDC tables use exact
comparison.
##########
sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/AutoCdcReservedNames.scala:
##########
@@ -40,4 +44,192 @@ private[pipelines] object AutoCdcReservedNames {
* Note that the schema of the CDC metadata column however can and does
differ on the SCD-type.
*/
val cdcMetadataColName: String = s"${prefix}metadata"
+
+ // A field is engine-owned when its name carries the reserved prefix.
Matching goes through the
+ // caller's `resolver` so a user-declared column is still recognized as
reserved under
+ // case-insensitive analysis, and the length guard keeps `substring` safe
for shorter names.
+ private def isReservedFieldName(name: String, resolver: Resolver): Boolean =
+ name.length >= prefix.length && resolver(name.substring(0, prefix.length),
prefix)
+
+ /** The engine-owned reserved AUTO CDC column(s) present in `schema`, if
any. */
+ private[pipelines] def reservedFields(schema: StructType, resolver:
Resolver): Seq[StructField] =
+ schema.fields.toSeq.filter(f => isReservedFieldName(f.name, resolver))
+
+ /** `schema` with the engine-owned reserved AUTO CDC column(s) removed. */
+ private[pipelines] def stripReservedFields(schema: StructType, resolver:
Resolver): StructType =
+ StructType(schema.fields.filterNot(f => isReservedFieldName(f.name,
resolver)))
+
+ /**
+ * The schema a materialized AUTO CDC target carries when the user declares
a schema: the user's
+ * columns with the engine-owned type/nullability substituted into any
declared reserved column,
+ * followed by the engine-owned reserved column(s) the declaration omitted.
`flowInferredSchema`
+ * must be flow-derived (no user-declared columns merged in), so its
reserved fields are exactly
+ * the engine-produced ones and never a user column that happens to carry
the prefix.
+ *
+ * A declared reserved field keeps its own spelling and position; only its
type and nullability
+ * are replaced with the engine's. That matters on the upgrade path: an
already-materialized
+ * target may carry the reserved column under a different casing (e.g. an
older definition
+ * declared it upper-cased under case-insensitive analysis), `evolveTable`
merges that existing
+ * spelling in, and this schema -- which also feeds the analysis-time read
path -- must agree with
+ * it so a downstream `SELECT *` plans against the column the target
actually has.
+ *
+ * The engine-owned reserved fields are made nullable so a target created
from a declared schema
+ * matches one created from an omitted schema (that path materializes the
inferred schema
+ * `asNullable`), avoiding a spurious nullability diff when a declaration is
added or removed.
+ */
+ private[pipelines] def appendEngineOwnedReservedFields(
+ declaredSchema: StructType,
+ flowInferredSchema: StructType,
+ resolver: Resolver): StructType = {
+ val engineReserved = StructType(reservedFields(flowInferredSchema,
resolver)).asNullable.fields
+
+ // A declaration may carry at most one field matching each engine-owned
reserved column; more
+ // than one (e.g. the metadata column declared twice under
case-insensitive analysis) is
+ // ambiguous and schema validation should have rejected it upstream.
+ engineReserved.foreach { ef =>
+ val matches = declaredSchema.fields.filter(df => resolver(df.name,
ef.name))
+ if (matches.length > 1) {
+ throw SparkException.internalError(
+ s"Ambiguous reserved AUTO CDC field:
${matches.map(_.name).mkString(", ")} all match " +
+ s"the engine-owned column ${ef.name}.")
+ }
+ }
+
+ // Rebuild the declared schema in place: a declared reserved field keeps
its spelling and
+ // position but takes the engine-owned type/nullability. A
reserved-prefixed declared field with
+ // no engine counterpart is left as-is (validation rejects it upstream for
an AUTO CDC target).
+ val usedEngineNames = scala.collection.mutable.Set.empty[String]
+ val rebuilt = declaredSchema.fields.map { df =>
+ if (isReservedFieldName(df.name, resolver)) {
+ engineReserved.find(ef => resolver(df.name, ef.name)) match {
+ case Some(ef) =>
+ usedEngineNames += ef.name
+ reconcileReservedField(df, ef, resolver)
+ case None => df
+ }
+ } else {
+ df
+ }
+ }
+
+ // Append engine-owned reserved fields the declaration omitted, in
canonical form.
+ val appended = engineReserved.filterNot(ef =>
usedEngineNames.contains(ef.name))
+ StructType(rebuilt ++ appended)
+ }
+
+ /**
+ * Reconcile a declared reserved field against its engine-owned counterpart:
keep the declared
+ * spelling, take the engine field's type and nullability, and reconcile the
types recursively
+ * (see `reconcileReservedType`) so declared nested spelling survives
through structs and arrays.
+ */
+ private def reconcileReservedField(
+ declared: StructField,
+ engine: StructField,
+ resolver: Resolver): StructField =
+ engine.copy(
Review Comment:
**Nit (P3):** `engine.copy(name = declared.name, ...)` keeps the engine
field's metadata, which is empty because it comes from
`AutoCdcMergeFlow.schema`, and discards the declared field's. The same happens
for nested fields through `reconcileReservedStruct`. The helper doc above says
only the type and nullability are replaced, and `SchemaInferenceUtils` merges
the declared schema in specifically to carry user metadata such as comments.
Before this PR, a declaration like `__spark_autocdc_metadata STRUCT<...>
COMMENT 'cdc bookkeeping'` was materialized verbatim. Now the comment never
reaches the created table or `VirtualTableInput.load`, and on a full refresh of
an existing target `diffSchemas` removes the persisted comment. Building from
the declared field instead, with its name and metadata plus the engine's
reconciled type and nullability, would match the documented contract.
**Verification:**
- **Behavior:** A declared AUTO CDC target whose reserved metadata column
carries a COMMENT is materialized with that comment while still receiving the
engine's type and nullability.
- **Regression:** A full refresh of an existing AUTO CDC target with a
commented declared reserved column leaves the comment in place.
##########
sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/AutoCdcReservedColumnMaterializationSuite.scala:
##########
@@ -0,0 +1,506 @@
+/*
+ * 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.{ArrayType, 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
+ extends ExecutionTest
+ with SharedSparkSession
+ with AutoCdcGraphExecutionTestMixin {
+
+ import testImplicits._
+
+ /** The engine's SCD1 metadata struct shape: `deleteSequence` /
`upsertSequence`, both long. */
+ private def scd1MetadataType: StructType = new StructType()
+ .add(Scd1BatchProcessor.cdcDeleteSequenceFieldName, LongType)
+ .add(Scd1BatchProcessor.cdcUpsertSequenceFieldName, LongType)
+
+ test("materialization appends the engine-owned reserved metadata column when
the user " +
+ "schema omits it") {
+ // The user declares only the logical data columns and omits the
engine-owned reserved
+ // metadata column. Materialization must append it so the created target
has exactly what the
+ // AUTO CDC MERGE writes; otherwise the MERGE fails with an unresolved
metadata column.
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("name", StringType)
+ .add("version", LongType, nullable = false)
+
+ val stream = MemoryStream[(Int, String, Long)]
+ stream.addData((1, "alice", 5L))
+ val ctx = new TestGraphRegistrationContext(spark) {
+ registerTable(
+ "target",
+ catalog = Some(catalog),
+ database = Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(
+ name = "auto_cdc_flow",
+ target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "name", "version")),
+ keys = Seq("id"),
+ sequencing = functions.col("version")))
+ }
+ runPipeline(ctx)
+
+ val targetSchema = spark.table(s"$catalog.$namespace.target").schema
+ assert(
+
targetSchema.fieldNames.contains(AutoCdcReservedNames.cdcMetadataColName),
+ "target should carry the engine-owned reserved metadata column, got " +
+ targetSchema.fieldNames.mkString(", "))
+ checkAnswer(
+ spark.table(s"$catalog.$namespace.target"),
+ Seq(Row(1, "alice", 5L, cdcMeta(None, Some(5L))))
+ )
+ }
+
+ test("materialization matches the reserved metadata column through the
flow's effective case " +
+ "sensitivity, not the session's") {
+ // The session is case-sensitive, but the flow sets
spark.sql.caseSensitive=false, so the
+ // flow's effective analysis is case-insensitive -- the same resolver the
AUTO CDC MERGE uses.
+ // A user who declares the reserved column as __SPARK_AUTOCDC_METADATA
(upper case) is then
+ // declaring the engine-owned column, so materialization must recognize it
as reserved and end
+ // up with a single engine-owned column, not keep the upper-case one and
append a lower-case
+ // duplicate. Matching against the session resolver (case-sensitive) here
would leave two.
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+ .add(AutoCdcReservedNames.cdcMetadataColName.toUpperCase(Locale.ROOT),
scd1MetadataType)
+
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData((1, 5L))
+ val ctx = new TestGraphRegistrationContext(
+ spark,
+ Map(SQLConf.CASE_SENSITIVE.key -> "false")) {
+ registerTable(
+ "target",
+ catalog = Some(catalog),
+ database = Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(
+ name = "auto_cdc_flow",
+ target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"),
+ sequencing = functions.col("version")))
+ }
+ runPipeline(ctx)
+
+ val reserved =
spark.table(s"$catalog.$namespace.target").schema.fieldNames
+
.filter(_.toLowerCase(Locale.ROOT).startsWith(AutoCdcReservedNames.prefix))
+ // Exactly one reserved column (no duplicate), which is the point of the
effective-resolver
+ // fix. Its spelling is the one the user declared -- the engine
substitutes only the type,
+ // keeping the declared casing and position.
+ assert(reserved.length == 1,
+ "expected exactly one engine-owned metadata column, got " +
reserved.mkString(", "))
+ checkAnswer(
+ spark.table(s"$catalog.$namespace.target"),
+ Seq(Row(1, 5L, cdcMeta(None, Some(5L))))
+ )
+ }
+ }
+
+ test("materialization keeps a differently-cased user column distinct from
the engine's " +
+ "reserved column when the flow is case-sensitive") {
+ // Reverse of the previous test: the session is case-insensitive but the
flow sets
+ // spark.sql.caseSensitive=true. Under the flow's case-sensitive analysis
a user-declared
+ // __SPARK_AUTOCDC_METADATA is a genuinely distinct column from the
engine's lower-case
+ // __spark_autocdc_metadata, so both survive. The engine still owns and
writes only the
+ // lower-case column (nullable=false); the user's upper-case column is
left unpopulated.
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") {
+ val userColName =
AutoCdcReservedNames.cdcMetadataColName.toUpperCase(Locale.ROOT)
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+ .add(userColName, scd1MetadataType)
+
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData((1, 5L))
+ val ctx = new TestGraphRegistrationContext(
+ spark,
+ Map(SQLConf.CASE_SENSITIVE.key -> "true")) {
+ registerTable(
+ "target",
+ catalog = Some(catalog),
+ database = Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(
+ name = "auto_cdc_flow",
+ target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"),
+ sequencing = functions.col("version")))
+ }
+ runPipeline(ctx)
+
+ val schema = spark.table(s"$catalog.$namespace.target").schema
+ // Both the user column and the engine column survive; case-sensitive
analysis keeps them
+ // distinct.
+ assert(schema.fieldNames.contains(userColName),
schema.fieldNames.mkString(", "))
+ assert(
+ schema.fieldNames.contains(AutoCdcReservedNames.cdcMetadataColName),
+ schema.fieldNames.mkString(", "))
+ // Read under case-sensitive analysis so the two case-differing columns
stay distinct. The
+ // engine writes its metadata to the lower-case column; the user's
upper-case column is left
+ // unpopulated (null).
+ withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") {
+ checkAnswer(
+ spark.table(s"$catalog.$namespace.target"),
+ Seq(Row(1, 5L, null, cdcMeta(None, Some(5L))))
+ )
+ }
+ }
+ }
+
+ test("a same-graph downstream SELECT * sees the target's materialized
schema, including the " +
+ "engine-owned reserved column") {
+ // The target declares a data-only schema (omits the reserved metadata
column). A downstream
+ // dataset in the same graph reads it with SELECT *. Its plan-time view of
the target (through
+ // VirtualTableInput) must match the schema the target is actually
materialized with, otherwise
+ // the plan carries the 3-column declaration while execution reads the
4-column table.
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("name", StringType)
+ .add("version", LongType, nullable = false)
+ val stream = MemoryStream[(Int, String, Long)]
+ stream.addData((1, "alice", 5L))
+ val ctx = new TestGraphRegistrationContext(spark) {
+ registerTable("target", catalog = Some(catalog), database =
Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(name = "writer", target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "name", "version")),
+ keys = Seq("id"), sequencing = functions.col("version")))
+ registerMaterializedView("enriched", catalog = Some(catalog), database =
Some(namespace),
+ query = readFlowFunc(s"$catalog.$namespace.target"))
+ }
+ runPipeline(ctx)
+
+ val targetFields =
spark.table(s"$catalog.$namespace.target").schema.fieldNames.toSeq
+ val enrichedFields =
spark.table(s"$catalog.$namespace.enriched").schema.fieldNames.toSeq
+ assert(
+ enrichedFields == targetFields,
+ s"downstream consumer schema ${enrichedFields.mkString(",")} should
match the materialized " +
+ s"target schema ${targetFields.mkString(",")}")
+ assert(enrichedFields.contains(AutoCdcReservedNames.cdcMetadataColName))
+ checkAnswer(
+ spark.table(s"$catalog.$namespace.enriched"),
+ Seq(Row(1, "alice", 5L, cdcMeta(None, Some(5L))))
+ )
+ }
+
+ test("the engine-owned reserved column is nullable when the user declares a
data-only schema, " +
+ "matching the omitted-schema path") {
+ // A target created from a data-only declaration must get the same
nullable metadata column as
+ // one created with no declared schema (that path materializes the
inferred schema asNullable),
+ // so adding or removing a declaration between runs does not surface as a
nullability change.
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData((1, 5L))
+ val ctx = new TestGraphRegistrationContext(spark) {
+ registerTable("target", catalog = Some(catalog), database =
Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(name = "auto_cdc_flow", target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"), sequencing = functions.col("version")))
+ }
+ runPipeline(ctx)
+ val metaField = spark.table(s"$catalog.$namespace.target")
+ .schema(AutoCdcReservedNames.cdcMetadataColName)
+ assert(metaField.nullable, "engine-owned reserved metadata column should
be nullable")
+ }
+
+ test("a data-only declaration on an already-materialized target is stable
across an " +
+ "incremental re-run") {
+ // The second run reaches the target through
evolveTable/mergeWithExistingSchema rather than a
+ // fresh create. Because the declared-schema path produces the same
engine-owned shape the
+ // target already carries, the schema must not churn between runs.
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+ def buildCtx(rows: Seq[(Int, Long)]): TestGraphRegistrationContext = {
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData(rows: _*)
+ new TestGraphRegistrationContext(spark) {
+ registerTable("target", catalog = Some(catalog), database =
Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(name = "auto_cdc_flow", target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"), sequencing = functions.col("version")))
+ }
+ }
+ runPipeline(buildCtx(Seq((1, 5L))))
+ val schemaAfterRun1 = spark.table(s"$catalog.$namespace.target").schema
+ runPipeline(buildCtx(Seq((2, 3L))))
+ val schemaAfterRun2 = spark.table(s"$catalog.$namespace.target").schema
+ assert(
+ schemaAfterRun2 == schemaAfterRun1,
+ s"schema changed across incremental runs: $schemaAfterRun1 ->
$schemaAfterRun2")
+ }
+
+ test("an already-materialized target's reserved-column casing is preserved
for a " +
+ "case-sensitive downstream SELECT *") {
+ // Upgrade path: a target already exists in the catalog with the reserved
metadata column
+ // declared upper-cased (allowed under case-insensitive AUTO CDC).
evolveTable merges that
+ // existing spelling in, so the read path must report the SAME casing,
otherwise a
+ // case-sensitive downstream `SELECT *` plans the canonical lower-case
name and cannot resolve
+ // the upper-case column the target actually has.
+ val upperMeta =
AutoCdcReservedNames.cdcMetadataColName.toUpperCase(Locale.ROOT)
+ val del = Scd1BatchProcessor.cdcDeleteSequenceFieldName
+ val ups = Scd1BatchProcessor.cdcUpsertSequenceFieldName
+ spark.sql(
+ s"CREATE TABLE $catalog.$namespace.target " +
+ s"(id INT NOT NULL, version BIGINT NOT NULL, " +
+ s"$upperMeta STRUCT<$del:BIGINT,$ups:BIGINT> NOT NULL)")
+
+ val metadataType = new StructType().add(del, LongType).add(ups, LongType)
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+ .add(upperMeta, metadataType, nullable = false)
+
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData((1, 5L))
+ val ctx = new TestGraphRegistrationContext(spark) {
+ registerTable("target", catalog = Some(catalog), database =
Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(name = "auto_cdc_flow", target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"), sequencing = functions.col("version")))
+ registerMaterializedView("copy", catalog = Some(catalog), database =
Some(namespace),
+ sqlConf = Map(SQLConf.CASE_SENSITIVE.key -> "true"),
+ query = readFlowFunc(s"$catalog.$namespace.target"))
+ }
+ runPipeline(ctx)
+
+ val targetFields =
spark.table(s"$catalog.$namespace.target").schema.fieldNames.toSeq
+ val copyFields =
spark.table(s"$catalog.$namespace.copy").schema.fieldNames.toSeq
+ assert(targetFields.contains(upperMeta),
+ "target should keep the declared upper-case reserved column, got " +
+ targetFields.mkString(","))
+ assert(copyFields == targetFields,
+ s"downstream copy ${copyFields.mkString(",")} should match " +
+ s"target ${targetFields.mkString(",")}")
+ }
+
+ test("an already-materialized target with an upper-cased reserved column
rejects a data-only " +
+ "declaration that omits it") {
+ // Upgrade path, the omitted-declaration case: a target already exists
with the reserved
+ // metadata column upper-cased (allowed under case-insensitive AUTO CDC).
The user adopts the
+ // new data-only declaration and drops the column, so this run resolves it
to the canonical
+ // lower-case name while the incremental merge keeps the existing
upper-case one. A
+ // case-sensitive downstream `SELECT *` could not match the two names, so
the run is rejected
+ // up front with an actionable error rather than left to fail opaquely
during execution.
+ // Reconciling at analysis time is not possible: the read path cannot see
the existing catalog
+ // casing, nor whether this run keeps it (incremental) or rewrites it to
canonical (refresh).
+ val upperMeta =
AutoCdcReservedNames.cdcMetadataColName.toUpperCase(Locale.ROOT)
+ val del = Scd1BatchProcessor.cdcDeleteSequenceFieldName
+ val ups = Scd1BatchProcessor.cdcUpsertSequenceFieldName
+ spark.sql(
+ s"CREATE TABLE $catalog.$namespace.target " +
+ s"(id INT NOT NULL, version BIGINT NOT NULL, " +
+ s"$upperMeta STRUCT<$del:BIGINT,$ups:BIGINT> NOT NULL)")
+
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData((1, 5L))
+ val ctx = new TestGraphRegistrationContext(spark) {
+ registerTable("target", catalog = Some(catalog), database =
Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(name = "auto_cdc_flow", target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"), sequencing = functions.col("version")))
+ }
+
+ val ex = intercept[RuntimeException] { runPipeline(ctx) }
+ checkErrorInPipelineFailure(
+ failure = ex,
+ condition =
"AUTOCDC_INVALID_STATE.RESERVED_METADATA_COLUMN_CASING_DRIFT",
+ sqlState = Some("42000"),
+ parameters = Map(
+ "tableName" ->
+ fullyQualifiedIdentifier("target", Some(catalog),
Some(namespace)).unquotedString,
+ "existingColumnName" -> upperMeta,
+ "resolvedColumnName" -> AutoCdcReservedNames.cdcMetadataColName
+ )
+ )
+ }
+
+ test("an already-materialized target's reserved-column nested casing is
preserved for a " +
+ "case-sensitive downstream SELECT *") {
+ // Upgrade path at the nested level: the target already carries the
reserved metadata column
+ // with its nested fields declared upper-cased (allowed under
case-insensitive AUTO CDC).
+ // evolveTable merges that existing spelling in, so the read path must
report the same nested
+ // casing, otherwise a case-sensitive downstream `SELECT *` plans the
canonical lower-case
+ // nested names and cannot resolve the upper-case fields the target
actually has.
+ val meta = AutoCdcReservedNames.cdcMetadataColName
+ val upperDel =
Scd1BatchProcessor.cdcDeleteSequenceFieldName.toUpperCase(Locale.ROOT)
+ val upperUps =
Scd1BatchProcessor.cdcUpsertSequenceFieldName.toUpperCase(Locale.ROOT)
+ spark.sql(
+ s"CREATE TABLE $catalog.$namespace.target " +
+ s"(id INT NOT NULL, version BIGINT NOT NULL, " +
+ s"$meta STRUCT<$upperDel:BIGINT,$upperUps:BIGINT> NOT NULL)")
+
+ val metadataType = new StructType().add(upperDel, LongType).add(upperUps,
LongType)
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+ .add(meta, metadataType, nullable = false)
+
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData((1, 5L))
+ val ctx = new TestGraphRegistrationContext(spark) {
+ registerTable("target", catalog = Some(catalog), database =
Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(name = "auto_cdc_flow", target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"), sequencing = functions.col("version")))
+ registerMaterializedView("copy", catalog = Some(catalog), database =
Some(namespace),
+ sqlConf = Map(SQLConf.CASE_SENSITIVE.key -> "true"),
+ query = readFlowFunc(s"$catalog.$namespace.target"))
+ }
+ runPipeline(ctx)
+
+ def nestedNames(table: String): Seq[String] =
+
spark.table(table).schema(meta).dataType.asInstanceOf[StructType].fieldNames.toSeq
+ val targetNested = nestedNames(s"$catalog.$namespace.target")
+ val copyNested = nestedNames(s"$catalog.$namespace.copy")
+ assert(targetNested == Seq(upperDel, upperUps),
+ s"target should keep the declared upper-case nested names, got
${targetNested.mkString(",")}")
+ assert(copyNested == targetNested,
+ s"downstream copy nested ${copyNested.mkString(",")} should match " +
+ s"target ${targetNested.mkString(",")}")
+ }
+
+ test("an already-materialized target with upper-cased reserved nested fields
rejects a " +
+ "data-only declaration that omits the metadata column") {
+ // Upgrade path, the omitted-declaration case at the nested level: the
target already carries
+ // the reserved metadata column with upper-cased nested fields. The user
adopts a data-only
+ // declaration and drops the column, so this run resolves the nested
fields to their canonical
+ // lower-case names while the incremental merge keeps the existing
upper-case ones. A
+ // case-sensitive downstream `SELECT *` could not match the two, so the
run is rejected up front
+ // with the same actionable error used for the outer column.
+ val meta = AutoCdcReservedNames.cdcMetadataColName
+ val del = Scd1BatchProcessor.cdcDeleteSequenceFieldName
+ val upperDel = del.toUpperCase(Locale.ROOT)
+ val upperUps =
Scd1BatchProcessor.cdcUpsertSequenceFieldName.toUpperCase(Locale.ROOT)
+ spark.sql(
+ s"CREATE TABLE $catalog.$namespace.target " +
+ s"(id INT NOT NULL, version BIGINT NOT NULL, " +
+ s"$meta STRUCT<$upperDel:BIGINT,$upperUps:BIGINT> NOT NULL)")
+
+ val declaredSchema = new StructType()
+ .add("id", IntegerType, nullable = false)
+ .add("version", LongType, nullable = false)
+
+ val stream = MemoryStream[(Int, Long)]
+ stream.addData((1, 5L))
+ val ctx = new TestGraphRegistrationContext(spark) {
+ registerTable("target", catalog = Some(catalog), database =
Some(namespace),
+ specifiedSchema = Some(declaredSchema))
+ registerFlow(autoCdcFlow(name = "auto_cdc_flow", target = "target",
+ query = dfFlowFunc(stream.toDF().toDF("id", "version")),
+ keys = Seq("id"), sequencing = functions.col("version")))
+ }
+
+ val ex = intercept[RuntimeException] { runPipeline(ctx) }
+ checkErrorInPipelineFailure(
+ failure = ex,
+ condition =
"AUTOCDC_INVALID_STATE.RESERVED_METADATA_COLUMN_CASING_DRIFT",
+ sqlState = Some("42000"),
+ parameters = Map(
+ "tableName" ->
+ fullyQualifiedIdentifier("target", Some(catalog),
Some(namespace)).unquotedString,
+ "existingColumnName" -> s"$meta.$upperDel",
+ "resolvedColumnName" -> s"$meta.$del"
+ )
+ )
+ }
+
+ test("a declared reserved column's array-of-struct nested casing is
preserved for a " +
Review Comment:
**Nit (P3):** This is the only array-of-struct case, and it creates the
target fresh, so `existingTableOpt` is empty and the casing-drift check never
runs. That includes the `ArrayType` branch of `typeCasingDrifts` added in the
latest commit. Both drift-rejection tests use plain struct nesting, so deleting
the array branch leaves the suite green. Two other new paths also lack a
failure signal. No test runs a full refresh over a drifted target, even though
that is the remedy the new error advertises and it depends on the check staying
behind `isTableIncrementallyUpdated`. And SCD2 data-only declarations are only
validated in UserSpecifiedSchemaValidationSuite; nothing materializes such a
target or runs the SCD2 MERGE against it.
This extends an [existing
discussion](https://github.com/apache/spark/pull/57644#discussion_r4330516972).
**Verification:**
- **Regression:** An existing target whose metadata sequencing field is an
array of structs with a differently cased element field, rerun with a data-only
declaration, fails with the casing-drift error.
- **Behavior:** A full refresh over a target with a drifted reserved
spelling succeeds, leaves the canonical spelling, and a case-sensitive
downstream SELECT * matches the target.
- **Behavior:** An SCD2 AUTO CDC target with a data-only declaration is
created with the engine-owned metadata column and produces the expected SCD2
rows.
--
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]