This is an automated email from the ASF dual-hosted git repository.

szehon-ho pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 3d5936176cb2 [SPARK-58247][CONNECT][SDP] Support SCD Type 2 in the 
Auto CDC Python and Spark Connect APIs
3d5936176cb2 is described below

commit 3d5936176cb23416e62d8dc15d406ac1b33c7064
Author: Andreas Neumann <[email protected]>
AuthorDate: Wed Jul 22 17:26:05 2026 -0700

    [SPARK-58247][CONNECT][SDP] Support SCD Type 2 in the Auto CDC Python and 
Spark Connect APIs
    
    ### What changes were proposed in this pull request?
    
    Expose Auto CDC SCD Type 2 through the Python and Spark Connect APIs. 
`create_auto_cdc_flow` now accepts `stored_as_scd_type=2` (or `"2"`) and the 
SCD2-only `track_history_column_list` / `track_history_except_column_list` 
parameters, which flow through to `ChangeArgs(storedAsScdType = Type2, 
trackHistorySelection = ...)` on the server.
    
    Specifically:
    - Proto: add `SCD_TYPE_2` to the `SCDType` enum and 
`track_history_column_list` (field 11) / `track_history_except_column_list` 
(field 12) to `AutoCdcFlowDetails`. Field numbers match the Databricks runtime 
extension proto for wire compatibility. Regenerate the Python stubs.
    - Python API (`api.py`, `flow.py`): widen `stored_as_scd_type`, add and 
normalize the track-history parameters, and reject track-history columns unless 
SCD2 is selected.
    - Serialization (`spark_connect_graph_element_registry.py`): map the SCD 
type and serialize the track-history columns.
    - Server (`PipelinesHandler.scala`): map `SCD_TYPE_2` to `ScdType.Type2`, 
build `trackHistorySelection`, and reject specifying both track-history lists.
    - Add error condition 
`AUTOCDC_BOTH_TRACK_HISTORY_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST`.
    
    The engine's SCD2 execution path is landing in a parallel workstream and 
still rejects SCD2 during graph analysis (`AUTOCDC_SCD2_NOT_SUPPORTED`). This 
change wires the API to the `ChangeArgs` contract those changes plug into; 
end-to-end SCD2 execution starts working once the engine drops those throws, 
with no further API change required.
    
    ### Why are the changes needed?
    
    The Python and Spark Connect Auto CDC APIs previously only supported SCD 
Type 1. SCD Type 2 (history tracking) is a common CDC requirement and is 
already modeled in the engine's `ChangeArgs`/`ScdType`.
    
    ### Does this PR introduce any user-facing change?
    
    Yes. `create_auto_cdc_flow` accepts `stored_as_scd_type=2` and the 
`track_history_column_list` / `track_history_except_column_list` parameters.
    
    ### How was this patch tested?
    
    New unit tests in `test_auto_cdc_flow.py` (SCD2 accept as int/str, 
track-history include/exclude, track-history-requires-SCD2 rejection) and 
`PythonPipelineSuite.scala` (SCD2 registration forwards to `ChangeArgs`, 
both-lists rejection). Ran the Python and Scala suites plus ruff/mypy.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Generated-by: Opus 4.8
    
    Closes #57412 from anew/spark-58247-autocdc-scd2-python-api.
    
    Authored-by: Andreas Neumann <[email protected]>
    Signed-off-by: Szehon Ho <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  12 ++
 python/pyspark/pipelines/api.py                    |  92 +++++++++++-
 python/pyspark/pipelines/flow.py                   |  12 +-
 .../spark_connect_graph_element_registry.py        |   8 +-
 .../pyspark/pipelines/tests/test_auto_cdc_flow.py  | 156 ++++++++++++++++++++-
 python/pyspark/sql/connect/proto/pipelines_pb2.py  |  76 +++++-----
 python/pyspark/sql/connect/proto/pipelines_pb2.pyi |  34 +++++
 .../main/protobuf/spark/connect/pipelines.proto    |   9 ++
 .../sql/connect/pipelines/PipelinesHandler.scala   |  32 ++++-
 .../connect/pipelines/PythonPipelineSuite.scala    | 110 ++++++++++++++-
 .../SparkDeclarativePipelinesServerSuite.scala     |  96 +++++++++++++
 11 files changed, 581 insertions(+), 56 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 875be3d75158..12b8abbaf3f5 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -221,6 +221,12 @@
     ],
     "sqlState" : "42613"
   },
+  "AUTOCDC_BOTH_TRACK_HISTORY_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST" : {
+    "message" : [
+      "AutoCDC flow specifies both `track_history_column_list` and 
`track_history_except_column_list`; at most one may be provided."
+    ],
+    "sqlState" : "42613"
+  },
   "AUTOCDC_COLUMNS_NOT_FOUND_IN_SCHEMA" : {
     "message" : [
       "Using <caseSensitivity> column name comparison, the following columns 
are not present in the <schemaName> schema: <missingColumns>. Available 
columns: <availableColumns>."
@@ -343,6 +349,12 @@
     ],
     "sqlState" : "0A000"
   },
+  "AUTOCDC_TRACK_HISTORY_REQUIRES_SCD2" : {
+    "message" : [
+      "AutoCDC flow specifies `track_history_column_list` or 
`track_history_except_column_list` but is not stored as SCD Type 2; 
history-tracking columns are only supported for SCD Type 2 target tables."
+    ],
+    "sqlState" : "42613"
+  },
   "AVRO_CANNOT_WRITE_NULL_FIELD" : {
     "message" : [
       "Cannot write null value for field <name> defined as non-null Avro data 
type <dataType>.",
diff --git a/python/pyspark/pipelines/api.py b/python/pyspark/pipelines/api.py
index 5cbc003708f2..19bd47e4af76 100644
--- a/python/pyspark/pipelines/api.py
+++ b/python/pyspark/pipelines/api.py
@@ -16,7 +16,7 @@
 #
 from typing import Callable, Dict, List, Literal, Optional, Union, overload
 
-from pyspark.errors import PySparkTypeError
+from pyspark.errors import PySparkTypeError, PySparkValueError
 from pyspark.pipelines.graph_element_registry import 
get_active_graph_element_registry
 from pyspark.pipelines.type_error_utils import 
validate_optional_list_of_str_arg
 from pyspark.pipelines.flow import AutoCdcFlow, Flow, QueryFunction
@@ -536,8 +536,11 @@ def create_auto_cdc_flow(
     apply_as_deletes: Optional[Union[str, Column]] = None,
     column_list: Optional[Union[List[str], List[Column]]] = None,
     except_column_list: Optional[Union[List[str], List[Column]]] = None,
-    stored_as_scd_type: Optional[Literal[1, "1"]] = None,
+    stored_as_scd_type: Optional[Literal[1, 2, "1", "2"]] = None,
     name: Optional[str] = None,
+    *,
+    track_history_column_list: Optional[Union[List[str], List[Column]]] = None,
+    track_history_except_column_list: Optional[Union[List[str], List[Column]]] 
= None,
 ) -> None:
     """
     Create an Auto CDC flow into the target table from the Change Data Capture 
(CDC) source.
@@ -581,8 +584,20 @@ def create_auto_cdc_flow(
         PySpark Columns. Only one of column_list and except_column_list can be 
specified. When \
         this is specified, all columns in the `DataFrame` of the target table 
except those in \
         this list will be in the output table.
-    :param stored_as_scd_type: The SCD type for the target table. Only 1 (or 
"1") is supported. \
-        When not specified, the server default applies.
+    :param stored_as_scd_type: The SCD type for the target table. 1 (or "1") 
and 2 (or "2") are \
+        supported. When not specified, the server default applies.
+    :param track_history_column_list: SCD2-only. Columns whose value change 
opens a new history \
+        record; two consecutive upsert events for the same key are coalesced 
into the same \
+        history record when they agree on every tracked column. When not 
specified, every \
+        eligible selected user column is tracked. This should be a list of 
column identifiers \
+        without qualifiers, expressed as either Python strings or PySpark 
Columns. Only one of \
+        track_history_column_list and track_history_except_column_list can be 
specified, and \
+        both require stored_as_scd_type to be 2.
+    :param track_history_except_column_list: SCD2-only. Columns excluded from 
history tracking. \
+        This should be a list of column identifiers without qualifiers, 
expressed as either \
+        Python strings or PySpark Columns. Only one of 
track_history_column_list and \
+        track_history_except_column_list can be specified, and both require 
stored_as_scd_type \
+        to be 2.
     :param name: The name of the flow for this create_auto_cdc_flow command. 
When unspecified, \
         this will build a "default flow" with name equal to the target name.
     """
@@ -627,6 +642,27 @@ def create_auto_cdc_flow(
     except_column_list = _normalize_optional_column_list(
         arg_name="except_column_list", column_list=except_column_list
     )
+    track_history_column_list = _normalize_optional_column_list(
+        arg_name="track_history_column_list", 
column_list=track_history_column_list
+    )
+    track_history_except_column_list = _normalize_optional_column_list(
+        arg_name="track_history_except_column_list",
+        column_list=track_history_except_column_list,
+    )
+
+    # An include/except pair is mutually exclusive. The server enforces this 
too, but failing
+    # here avoids a round-trip. An empty list serializes identically to an 
omitted one (an unset
+    # repeated field on the wire), so it is a no-op; gate on non-empty lists 
rather than
+    # `is not None`.
+    _reject_include_and_except_together(
+        "column_list", column_list, "except_column_list", except_column_list
+    )
+    _reject_include_and_except_together(
+        "track_history_column_list",
+        track_history_column_list,
+        "track_history_except_column_list",
+        track_history_except_column_list,
+    )
 
     if isinstance(sequence_by, str):
         sequence_by = _connect_expr(sequence_by)
@@ -652,16 +688,40 @@ def create_auto_cdc_flow(
             },
         )
 
-    if stored_as_scd_type is not None and str(stored_as_scd_type) != "1":
+    if stored_as_scd_type is not None and str(stored_as_scd_type) not in ("1", 
"2"):
         raise PySparkTypeError(
             errorClass="NOT_EXPECTED_TYPE",
             messageParameters={
                 "arg_name": "stored_as_scd_type",
-                "expected_type": "Literal[1, '1']",
+                "expected_type": "Literal[1, 2, '1', '2']",
                 "arg_type": type(stored_as_scd_type).__name__,
             },
         )
 
+    # Track-history columns describe how consecutive upserts are coalesced 
into history records,
+    # a notion that only exists under SCD2. Reject them for any other SCD 
type. The engine
+    # enforces this too, but failing here gives a clearer, client-side error.
+    #
+    # An empty list serializes identically to an omitted one (an unset 
repeated field on the
+    # wire) and is therefore a no-op, so gate on non-empty lists rather than 
`is not None`.
+    is_scd2 = stored_as_scd_type is not None and str(stored_as_scd_type) == "2"
+    supplied_track_history_args = [
+        arg_name
+        for arg_name, cols in (
+            ("track_history_column_list", track_history_column_list),
+            ("track_history_except_column_list", 
track_history_except_column_list),
+        )
+        if cols
+    ]
+    if not is_scd2 and supplied_track_history_args:
+        raise PySparkValueError(
+            errorClass="INVALID_MULTIPLE_ARGUMENT_CONDITIONS",
+            messageParameters={
+                "arg_names": ", ".join(supplied_track_history_args),
+                "condition": "specified unless stored_as_scd_type is 2 (or 
'2')",
+            },
+        )
+
     source_code_location = get_caller_source_code_location(stacklevel=1)
 
     flow = AutoCdcFlow(
@@ -674,12 +734,32 @@ def create_auto_cdc_flow(
         column_list=column_list,
         except_column_list=except_column_list,
         stored_as_scd_type=stored_as_scd_type,
+        track_history_column_list=track_history_column_list,
+        track_history_except_column_list=track_history_except_column_list,
         source_code_location=source_code_location,
     )
 
     get_active_graph_element_registry().register_auto_cdc_flow(flow)
 
 
+def _reject_include_and_except_together(
+    include_arg_name: str,
+    include_columns: Optional[List[Column]],
+    except_arg_name: str,
+    except_columns: Optional[List[Column]],
+) -> None:
+    """Reject specifying a non-empty include list and a non-empty except list 
together.
+
+    An empty list is a no-op (it serializes to an unset repeated field, 
indistinguishable from an
+    omitted argument), so only non-empty lists count as "specified".
+    """
+    if include_columns and except_columns:
+        raise PySparkValueError(
+            errorClass="CANNOT_SET_TOGETHER",
+            messageParameters={"arg_list": f"{include_arg_name} and 
{except_arg_name}"},
+        )
+
+
 def _normalize_optional_column_list(
     arg_name: str,
     column_list: Optional[Union[List[str], List[Column]]],
diff --git a/python/pyspark/pipelines/flow.py b/python/pyspark/pipelines/flow.py
index b1922454a551..8a0f44e2abe9 100644
--- a/python/pyspark/pipelines/flow.py
+++ b/python/pyspark/pipelines/flow.py
@@ -59,8 +59,12 @@ class AutoCdcFlow:
     :param apply_as_deletes: Optional delete condition for the merge operation.
     :param column_list: Optional columns to include in the output table.
     :param except_column_list: Optional columns to exclude from the output 
table.
-    :param stored_as_scd_type: Optional SCD type for the target table. Only 1 
(or "1") is \
-        supported.
+    :param stored_as_scd_type: Optional SCD type for the target table. 1 (or 
"1") and 2 (or "2") \
+        are supported.
+    :param track_history_column_list: Optional SCD2-only columns whose value 
change opens a new \
+        history record.
+    :param track_history_except_column_list: Optional SCD2-only columns 
excluded from history \
+        tracking.
     :param source_code_location: The location of the source code that created 
this flow.
     """
 
@@ -72,5 +76,7 @@ class AutoCdcFlow:
     apply_as_deletes: Optional[Column]
     column_list: Optional[List[Column]]
     except_column_list: Optional[List[Column]]
-    stored_as_scd_type: Optional[Literal[1, "1"]]
+    stored_as_scd_type: Optional[Literal[1, 2, "1", "2"]]
+    track_history_column_list: Optional[List[Column]]
+    track_history_except_column_list: Optional[List[Column]]
     source_code_location: SourceCodeLocation
diff --git a/python/pyspark/pipelines/spark_connect_graph_element_registry.py 
b/python/pyspark/pipelines/spark_connect_graph_element_registry.py
index 2eef264990a3..a46eaca5f4cb 100644
--- a/python/pyspark/pipelines/spark_connect_graph_element_registry.py
+++ b/python/pyspark/pipelines/spark_connect_graph_element_registry.py
@@ -148,9 +148,15 @@ class 
SparkConnectGraphElementRegistry(GraphElementRegistry):
             sequence_by=to_plan(flow.sequence_by),
             column_list=to_plans(flow.column_list),
             except_column_list=to_plans(flow.except_column_list),
+            track_history_column_list=to_plans(flow.track_history_column_list),
+            
track_history_except_column_list=to_plans(flow.track_history_except_column_list),
         )
         if flow.stored_as_scd_type is not None:
-            auto_cdc_details.stored_as_scd_type = 
pb2.PipelineCommand.DefineFlow.SCDType.SCD_TYPE_1
+            scd_type_by_value = {
+                "1": pb2.PipelineCommand.DefineFlow.SCDType.SCD_TYPE_1,
+                "2": pb2.PipelineCommand.DefineFlow.SCDType.SCD_TYPE_2,
+            }
+            auto_cdc_details.stored_as_scd_type = 
scd_type_by_value[str(flow.stored_as_scd_type)]
         if flow.apply_as_deletes is not None:
             
auto_cdc_details.apply_as_deletes.CopyFrom(to_plan(flow.apply_as_deletes))
 
diff --git a/python/pyspark/pipelines/tests/test_auto_cdc_flow.py 
b/python/pyspark/pipelines/tests/test_auto_cdc_flow.py
index c52e8a09e8a6..c142e1b1562c 100644
--- a/python/pyspark/pipelines/tests/test_auto_cdc_flow.py
+++ b/python/pyspark/pipelines/tests/test_auto_cdc_flow.py
@@ -18,7 +18,7 @@
 import unittest
 from typing import cast
 
-from pyspark.errors import PySparkRuntimeError, PySparkTypeError
+from pyspark.errors import PySparkRuntimeError, PySparkTypeError, 
PySparkValueError
 from pyspark.sql import Column
 from pyspark.testing.connectutils import (
     should_test_connect,
@@ -117,6 +117,73 @@ class AutoCdcFlowConstructionTest(unittest.TestCase):
         flow = cast(AutoCdcFlow, registry.auto_cdc_flows[0])
         self.assertEqual(flow.stored_as_scd_type, "1")
 
+    def test_create_auto_cdc_flow_stored_as_scd_type_2(self):
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("t")
+            dp.create_auto_cdc_flow(
+                target="t",
+                source="s",
+                keys=[col("k")],
+                sequence_by=expr("seq"),
+                stored_as_scd_type=2,
+            )
+
+        flow = cast(AutoCdcFlow, registry.auto_cdc_flows[0])
+        self.assertEqual(flow.stored_as_scd_type, 2)
+
+    def test_create_auto_cdc_flow_stored_as_scd_type_2_string(self):
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("t")
+            dp.create_auto_cdc_flow(
+                target="t",
+                source="s",
+                keys=[col("k")],
+                sequence_by=expr("seq"),
+                stored_as_scd_type="2",
+            )
+
+        flow = cast(AutoCdcFlow, registry.auto_cdc_flows[0])
+        self.assertEqual(flow.stored_as_scd_type, "2")
+
+    def test_create_auto_cdc_flow_with_track_history_column_list(self):
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("t")
+            dp.create_auto_cdc_flow(
+                target="t",
+                source="s",
+                keys=[col("k")],
+                sequence_by=expr("seq"),
+                stored_as_scd_type=2,
+                track_history_column_list=["val"],
+            )
+
+        flow = cast(AutoCdcFlow, registry.auto_cdc_flows[0])
+        assert flow.track_history_column_list is not None
+        self.assertEqual(len(flow.track_history_column_list), 1)
+        self.assertIsInstance(flow.track_history_column_list[0], Column)
+        self.assertIsNone(flow.track_history_except_column_list)
+
+    def test_create_auto_cdc_flow_with_track_history_except_column_list(self):
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("t")
+            dp.create_auto_cdc_flow(
+                target="t",
+                source="s",
+                keys=[col("k")],
+                sequence_by=expr("seq"),
+                stored_as_scd_type=2,
+                track_history_except_column_list=["op", "seq"],
+            )
+
+        flow = cast(AutoCdcFlow, registry.auto_cdc_flows[0])
+        assert flow.track_history_except_column_list is not None
+        self.assertEqual(len(flow.track_history_except_column_list), 2)
+        self.assertIsNone(flow.track_history_column_list)
+
     def test_create_auto_cdc_flow_invalid_scd_type(self):
         registry = LocalGraphElementRegistry()
         with graph_element_registration_context(registry):
@@ -127,10 +194,44 @@ class AutoCdcFlowConstructionTest(unittest.TestCase):
                     source="s",
                     keys=[col("k")],
                     sequence_by=expr("seq"),
-                    stored_as_scd_type=2,  # type: ignore[arg-type]
+                    stored_as_scd_type=3,  # type: ignore[arg-type]
                 )
             self.assertEqual(ctx.exception.getCondition(), "NOT_EXPECTED_TYPE")
 
+    def test_create_auto_cdc_flow_track_history_requires_scd2(self):
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("t")
+            with self.assertRaises(PySparkValueError) as ctx:
+                dp.create_auto_cdc_flow(
+                    target="t",
+                    source="s",
+                    keys=[col("k")],
+                    sequence_by=expr("seq"),
+                    stored_as_scd_type=1,
+                    track_history_column_list=["val"],
+                )
+            self.assertEqual(ctx.exception.getCondition(), 
"INVALID_MULTIPLE_ARGUMENT_CONDITIONS")
+
+    def 
test_create_auto_cdc_flow_empty_track_history_list_is_accepted_without_scd2(self):
+        # An empty list serializes identically to an omitted one (an unset 
repeated field), so it
+        # is a no-op and must not trip the "track history requires SCD2" check.
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("t")
+            dp.create_auto_cdc_flow(
+                target="t",
+                source="s",
+                keys=[col("k")],
+                sequence_by=expr("seq"),
+                track_history_column_list=[],
+                track_history_except_column_list=[],
+            )
+
+        flow = cast(AutoCdcFlow, registry.auto_cdc_flows[0])
+        self.assertEqual(flow.track_history_column_list, [])
+        self.assertEqual(flow.track_history_except_column_list, [])
+
     def test_create_auto_cdc_flow_with_except_column_list(self):
         registry = LocalGraphElementRegistry()
         with graph_element_registration_context(registry):
@@ -150,6 +251,57 @@ class AutoCdcFlowConstructionTest(unittest.TestCase):
         for c in flow.except_column_list:
             self.assertIsInstance(c, Column)
 
+    def test_create_auto_cdc_flow_rejects_both_column_list_and_except(self):
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("tgt")
+            with self.assertRaises(PySparkValueError) as ctx:
+                dp.create_auto_cdc_flow(
+                    target="tgt",
+                    source="src",
+                    keys=[col("id")],
+                    sequence_by=expr("ts"),
+                    column_list=["id"],
+                    except_column_list=["op"],
+                )
+            self.assertEqual(ctx.exception.getCondition(), 
"CANNOT_SET_TOGETHER")
+
+    def 
test_create_auto_cdc_flow_rejects_both_track_history_column_list_and_except(self):
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("tgt")
+            with self.assertRaises(PySparkValueError) as ctx:
+                dp.create_auto_cdc_flow(
+                    target="tgt",
+                    source="src",
+                    keys=[col("id")],
+                    sequence_by=expr("ts"),
+                    stored_as_scd_type=2,
+                    track_history_column_list=["val"],
+                    track_history_except_column_list=["op"],
+                )
+            self.assertEqual(ctx.exception.getCondition(), 
"CANNOT_SET_TOGETHER")
+
+    def 
test_create_auto_cdc_flow_empty_column_lists_not_treated_as_both_set(self):
+        # Empty lists are no-ops (they serialize to unset repeated fields), so 
an empty include
+        # list alongside a non-empty except list must not trip the 
mutual-exclusivity check.
+        registry = LocalGraphElementRegistry()
+        with graph_element_registration_context(registry):
+            dp.create_streaming_table("tgt")
+            dp.create_auto_cdc_flow(
+                target="tgt",
+                source="src",
+                keys=[col("id")],
+                sequence_by=expr("ts"),
+                column_list=[],
+                except_column_list=["op"],
+            )
+
+        flow = cast(AutoCdcFlow, registry.auto_cdc_flows[0])
+        self.assertEqual(flow.column_list, [])
+        assert flow.except_column_list is not None
+        self.assertEqual(len(flow.except_column_list), 1)
+
     def test_create_auto_cdc_flow_rejects_non_str_target(self):
         registry = LocalGraphElementRegistry()
         with graph_element_registration_context(registry):
diff --git a/python/pyspark/sql/connect/proto/pipelines_pb2.py 
b/python/pyspark/sql/connect/proto/pipelines_pb2.py
index ec8142c1d2aa..c32f27f26cdc 100644
--- a/python/pyspark/sql/connect/proto/pipelines_pb2.py
+++ b/python/pyspark/sql/connect/proto/pipelines_pb2.py
@@ -44,7 +44,7 @@ from pyspark.sql.connect.proto import types_pb2 as 
spark_dot_connect_dot_types__
 
 
 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
-    
b'\n\x1dspark/connect/pipelines.proto\x12\rspark.connect\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1aspark/connect/common.proto\x1a\x1fspark/connect/expressions.proto\x1a\x1dspark/connect/relations.proto\x1a\x19spark/connect/types.proto"\x83/\n\x0fPipelineCommand\x12h\n\x15\x63reate_dataflow_graph\x18\x01
 
\x01(\x0b\x32\x32.spark.connect.PipelineCommand.CreateDataflowGraphH\x00R\x13\x63reateDataflowGraph\x12R\n\rdefine_output\x18\x02
 \x01(\x0b\x32+.s [...]
+    
b'\n\x1dspark/connect/pipelines.proto\x12\rspark.connect\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1aspark/connect/common.proto\x1a\x1fspark/connect/expressions.proto\x1a\x1dspark/connect/relations.proto\x1a\x19spark/connect/types.proto"\xcc\x30\n\x0fPipelineCommand\x12h\n\x15\x63reate_dataflow_graph\x18\x01
 
\x01(\x0b\x32\x32.spark.connect.PipelineCommand.CreateDataflowGraphH\x00R\x13\x63reateDataflowGraph\x12R\n\rdefine_output\x18\x02
 \x01(\x0b\x32 [...]
 )
 
 _globals = globals()
@@ -87,10 +87,10 @@ if not _descriptor._USE_C_DESCRIPTORS:
     _globals["_PIPELINEANALYSISCONTEXT"].fields_by_name[
         "flow_name"
     ]._serialized_options = b"\030\001"
-    _globals["_OUTPUTTYPE"]._serialized_start = 7966
-    _globals["_OUTPUTTYPE"]._serialized_end = 8071
+    _globals["_OUTPUTTYPE"]._serialized_start = 8167
+    _globals["_OUTPUTTYPE"]._serialized_end = 8272
     _globals["_PIPELINECOMMAND"]._serialized_start = 228
-    _globals["_PIPELINECOMMAND"]._serialized_end = 6247
+    _globals["_PIPELINECOMMAND"]._serialized_end = 6448
     _globals["_PIPELINECOMMAND_CREATEDATAFLOWGRAPH"]._serialized_start = 1265
     _globals["_PIPELINECOMMAND_CREATEDATAFLOWGRAPH"]._serialized_end = 1573
     
_globals["_PIPELINECOMMAND_CREATEDATAFLOWGRAPH_SQLCONFENTRY"]._serialized_start 
= 1474
@@ -112,43 +112,43 @@ if not _descriptor._USE_C_DESCRIPTORS:
     
_globals["_PIPELINECOMMAND_DEFINEOUTPUT_SINKDETAILS_OPTIONSENTRY"]._serialized_start
 = 2795
     
_globals["_PIPELINECOMMAND_DEFINEOUTPUT_SINKDETAILS_OPTIONSENTRY"]._serialized_end
 = 2853
     _globals["_PIPELINECOMMAND_DEFINEFLOW"]._serialized_start = 2969
-    _globals["_PIPELINECOMMAND_DEFINEFLOW"]._serialized_end = 4855
+    _globals["_PIPELINECOMMAND_DEFINEFLOW"]._serialized_end = 5056
     _globals["_PIPELINECOMMAND_DEFINEFLOW_SQLCONFENTRY"]._serialized_start = 
1474
     _globals["_PIPELINECOMMAND_DEFINEFLOW_SQLCONFENTRY"]._serialized_end = 1532
     
_globals["_PIPELINECOMMAND_DEFINEFLOW_WRITERELATIONFLOWDETAILS"]._serialized_start
 = 3703
     
_globals["_PIPELINECOMMAND_DEFINEFLOW_WRITERELATIONFLOWDETAILS"]._serialized_end
 = 3800
     
_globals["_PIPELINECOMMAND_DEFINEFLOW_AUTOCDCFLOWDETAILS"]._serialized_start = 
3803
-    _globals["_PIPELINECOMMAND_DEFINEFLOW_AUTOCDCFLOWDETAILS"]._serialized_end 
= 4623
-    _globals["_PIPELINECOMMAND_DEFINEFLOW_RESPONSE"]._serialized_start = 4625
-    _globals["_PIPELINECOMMAND_DEFINEFLOW_RESPONSE"]._serialized_end = 4683
-    _globals["_PIPELINECOMMAND_DEFINEFLOW_SCDTYPE"]._serialized_start = 4685
-    _globals["_PIPELINECOMMAND_DEFINEFLOW_SCDTYPE"]._serialized_end = 4736
-    _globals["_PIPELINECOMMAND_EXECUTEOUTPUTFLOWS"]._serialized_start = 4858
-    _globals["_PIPELINECOMMAND_EXECUTEOUTPUTFLOWS"]._serialized_end = 5214
-    _globals["_PIPELINECOMMAND_STARTRUN"]._serialized_start = 5217
-    _globals["_PIPELINECOMMAND_STARTRUN"]._serialized_end = 5539
-    _globals["_PIPELINECOMMAND_DEFINESQLGRAPHELEMENTS"]._serialized_start = 
5542
-    _globals["_PIPELINECOMMAND_DEFINESQLGRAPHELEMENTS"]._serialized_end = 5741
-    
_globals["_PIPELINECOMMAND_GETQUERYFUNCTIONEXECUTIONSIGNALSTREAM"]._serialized_start
 = 5744
-    
_globals["_PIPELINECOMMAND_GETQUERYFUNCTIONEXECUTIONSIGNALSTREAM"]._serialized_end
 = 5902
-    
_globals["_PIPELINECOMMAND_DEFINEFLOWQUERYFUNCTIONRESULT"]._serialized_start = 
5905
-    _globals["_PIPELINECOMMAND_DEFINEFLOWQUERYFUNCTIONRESULT"]._serialized_end 
= 6231
-    _globals["_PIPELINECOMMANDRESULT"]._serialized_start = 6250
-    _globals["_PIPELINECOMMANDRESULT"]._serialized_end = 7002
-    
_globals["_PIPELINECOMMANDRESULT_CREATEDATAFLOWGRAPHRESULT"]._serialized_start 
= 6619
-    
_globals["_PIPELINECOMMANDRESULT_CREATEDATAFLOWGRAPHRESULT"]._serialized_end = 
6717
-    _globals["_PIPELINECOMMANDRESULT_DEFINEOUTPUTRESULT"]._serialized_start = 
6720
-    _globals["_PIPELINECOMMANDRESULT_DEFINEOUTPUTRESULT"]._serialized_end = 
6853
-    _globals["_PIPELINECOMMANDRESULT_DEFINEFLOWRESULT"]._serialized_start = 
6856
-    _globals["_PIPELINECOMMANDRESULT_DEFINEFLOWRESULT"]._serialized_end = 6987
-    _globals["_PIPELINEEVENTRESULT"]._serialized_start = 7004
-    _globals["_PIPELINEEVENTRESULT"]._serialized_end = 7077
-    _globals["_PIPELINEEVENT"]._serialized_start = 7079
-    _globals["_PIPELINEEVENT"]._serialized_end = 7195
-    _globals["_SOURCECODELOCATION"]._serialized_start = 7198
-    _globals["_SOURCECODELOCATION"]._serialized_end = 7439
-    _globals["_PIPELINEQUERYFUNCTIONEXECUTIONSIGNAL"]._serialized_start = 7442
-    _globals["_PIPELINEQUERYFUNCTIONEXECUTIONSIGNAL"]._serialized_end = 7593
-    _globals["_PIPELINEANALYSISCONTEXT"]._serialized_start = 7596
-    _globals["_PIPELINEANALYSISCONTEXT"]._serialized_end = 7964
+    _globals["_PIPELINECOMMAND_DEFINEFLOW_AUTOCDCFLOWDETAILS"]._serialized_end 
= 4808
+    _globals["_PIPELINECOMMAND_DEFINEFLOW_RESPONSE"]._serialized_start = 4810
+    _globals["_PIPELINECOMMAND_DEFINEFLOW_RESPONSE"]._serialized_end = 4868
+    _globals["_PIPELINECOMMAND_DEFINEFLOW_SCDTYPE"]._serialized_start = 4870
+    _globals["_PIPELINECOMMAND_DEFINEFLOW_SCDTYPE"]._serialized_end = 4937
+    _globals["_PIPELINECOMMAND_EXECUTEOUTPUTFLOWS"]._serialized_start = 5059
+    _globals["_PIPELINECOMMAND_EXECUTEOUTPUTFLOWS"]._serialized_end = 5415
+    _globals["_PIPELINECOMMAND_STARTRUN"]._serialized_start = 5418
+    _globals["_PIPELINECOMMAND_STARTRUN"]._serialized_end = 5740
+    _globals["_PIPELINECOMMAND_DEFINESQLGRAPHELEMENTS"]._serialized_start = 
5743
+    _globals["_PIPELINECOMMAND_DEFINESQLGRAPHELEMENTS"]._serialized_end = 5942
+    
_globals["_PIPELINECOMMAND_GETQUERYFUNCTIONEXECUTIONSIGNALSTREAM"]._serialized_start
 = 5945
+    
_globals["_PIPELINECOMMAND_GETQUERYFUNCTIONEXECUTIONSIGNALSTREAM"]._serialized_end
 = 6103
+    
_globals["_PIPELINECOMMAND_DEFINEFLOWQUERYFUNCTIONRESULT"]._serialized_start = 
6106
+    _globals["_PIPELINECOMMAND_DEFINEFLOWQUERYFUNCTIONRESULT"]._serialized_end 
= 6432
+    _globals["_PIPELINECOMMANDRESULT"]._serialized_start = 6451
+    _globals["_PIPELINECOMMANDRESULT"]._serialized_end = 7203
+    
_globals["_PIPELINECOMMANDRESULT_CREATEDATAFLOWGRAPHRESULT"]._serialized_start 
= 6820
+    
_globals["_PIPELINECOMMANDRESULT_CREATEDATAFLOWGRAPHRESULT"]._serialized_end = 
6918
+    _globals["_PIPELINECOMMANDRESULT_DEFINEOUTPUTRESULT"]._serialized_start = 
6921
+    _globals["_PIPELINECOMMANDRESULT_DEFINEOUTPUTRESULT"]._serialized_end = 
7054
+    _globals["_PIPELINECOMMANDRESULT_DEFINEFLOWRESULT"]._serialized_start = 
7057
+    _globals["_PIPELINECOMMANDRESULT_DEFINEFLOWRESULT"]._serialized_end = 7188
+    _globals["_PIPELINEEVENTRESULT"]._serialized_start = 7205
+    _globals["_PIPELINEEVENTRESULT"]._serialized_end = 7278
+    _globals["_PIPELINEEVENT"]._serialized_start = 7280
+    _globals["_PIPELINEEVENT"]._serialized_end = 7396
+    _globals["_SOURCECODELOCATION"]._serialized_start = 7399
+    _globals["_SOURCECODELOCATION"]._serialized_end = 7640
+    _globals["_PIPELINEQUERYFUNCTIONEXECUTIONSIGNAL"]._serialized_start = 7643
+    _globals["_PIPELINEQUERYFUNCTIONEXECUTIONSIGNAL"]._serialized_end = 7794
+    _globals["_PIPELINEANALYSISCONTEXT"]._serialized_start = 7797
+    _globals["_PIPELINEANALYSISCONTEXT"]._serialized_end = 8165
 # @@protoc_insertion_point(module_scope)
diff --git a/python/pyspark/sql/connect/proto/pipelines_pb2.pyi 
b/python/pyspark/sql/connect/proto/pipelines_pb2.pyi
index f327a7e92ac8..70e04ad85042 100644
--- a/python/pyspark/sql/connect/proto/pipelines_pb2.pyi
+++ b/python/pyspark/sql/connect/proto/pipelines_pb2.pyi
@@ -519,12 +519,14 @@ class PipelineCommand(google.protobuf.message.Message):
             DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
             SCD_TYPE_UNSPECIFIED: 
PipelineCommand.DefineFlow._SCDType.ValueType  # 0
             SCD_TYPE_1: PipelineCommand.DefineFlow._SCDType.ValueType  # 1
+            SCD_TYPE_2: PipelineCommand.DefineFlow._SCDType.ValueType  # 2
 
         class SCDType(_SCDType, metaclass=_SCDTypeEnumTypeWrapper):
             """SCD Type for Auto CDC target tables."""
 
         SCD_TYPE_UNSPECIFIED: PipelineCommand.DefineFlow.SCDType.ValueType  # 0
         SCD_TYPE_1: PipelineCommand.DefineFlow.SCDType.ValueType  # 1
+        SCD_TYPE_2: PipelineCommand.DefineFlow.SCDType.ValueType  # 2
 
         class SqlConfEntry(google.protobuf.message.Message):
             DESCRIPTOR: google.protobuf.descriptor.Descriptor
@@ -588,6 +590,8 @@ class PipelineCommand(google.protobuf.message.Message):
             COLUMN_LIST_FIELD_NUMBER: builtins.int
             EXCEPT_COLUMN_LIST_FIELD_NUMBER: builtins.int
             STORED_AS_SCD_TYPE_FIELD_NUMBER: builtins.int
+            TRACK_HISTORY_COLUMN_LIST_FIELD_NUMBER: builtins.int
+            TRACK_HISTORY_EXCEPT_COLUMN_LIST_FIELD_NUMBER: builtins.int
             IGNORE_NULL_UPDATES_COLUMN_LIST_FIELD_NUMBER: builtins.int
             IGNORE_NULL_UPDATES_EXCEPT_COLUMN_LIST_FIELD_NUMBER: builtins.int
             source: builtins.str
@@ -625,6 +629,24 @@ class PipelineCommand(google.protobuf.message.Message):
             stored_as_scd_type: 
global___PipelineCommand.DefineFlow.SCDType.ValueType
             """SCD Type for target table."""
             @property
+            def track_history_column_list(
+                self,
+            ) -> 
google.protobuf.internal.containers.RepeatedCompositeFieldContainer[
+                pyspark.sql.connect.proto.expressions_pb2.Expression
+            ]:
+                """SCD2 only. Columns whose value change opens a new history 
record. When empty, every
+                eligible selected user column is tracked.
+                """
+            @property
+            def track_history_except_column_list(
+                self,
+            ) -> 
google.protobuf.internal.containers.RepeatedCompositeFieldContainer[
+                pyspark.sql.connect.proto.expressions_pb2.Expression
+            ]:
+                """SCD2 only. Columns excluded from history tracking. Mutually 
exclusive with
+                track_history_column_list.
+                """
+            @property
             def ignore_null_updates_column_list(
                 self,
             ) -> 
google.protobuf.internal.containers.RepeatedCompositeFieldContainer[
@@ -657,6 +679,14 @@ class PipelineCommand(google.protobuf.message.Message):
                 ]
                 | None = ...,
                 stored_as_scd_type: 
global___PipelineCommand.DefineFlow.SCDType.ValueType = ...,
+                track_history_column_list: collections.abc.Iterable[
+                    pyspark.sql.connect.proto.expressions_pb2.Expression
+                ]
+                | None = ...,
+                track_history_except_column_list: collections.abc.Iterable[
+                    pyspark.sql.connect.proto.expressions_pb2.Expression
+                ]
+                | None = ...,
                 ignore_null_updates_column_list: collections.abc.Iterable[
                     pyspark.sql.connect.proto.expressions_pb2.Expression
                 ]
@@ -718,6 +748,10 @@ class PipelineCommand(google.protobuf.message.Message):
                     b"source",
                     "stored_as_scd_type",
                     b"stored_as_scd_type",
+                    "track_history_column_list",
+                    b"track_history_column_list",
+                    "track_history_except_column_list",
+                    b"track_history_except_column_list",
                 ],
             ) -> None: ...
             @typing.overload
diff --git a/sql/connect/common/src/main/protobuf/spark/connect/pipelines.proto 
b/sql/connect/common/src/main/protobuf/spark/connect/pipelines.proto
index 7632ec95b9b5..7bfc5ca39826 100644
--- a/sql/connect/common/src/main/protobuf/spark/connect/pipelines.proto
+++ b/sql/connect/common/src/main/protobuf/spark/connect/pipelines.proto
@@ -182,6 +182,14 @@ message PipelineCommand {
       // SCD Type for target table.
       SCDType stored_as_scd_type = 10;
 
+      // SCD2 only. Columns whose value change opens a new history record. 
When empty, every
+      // eligible selected user column is tracked.
+      repeated Expression track_history_column_list = 11;
+
+      // SCD2 only. Columns excluded from history tracking. Mutually exclusive 
with
+      // track_history_column_list.
+      repeated Expression track_history_except_column_list = 12;
+
       // Subset of columns to ignore null in updates.
       repeated Expression ignore_null_updates_column_list = 14;
 
@@ -194,6 +202,7 @@ message PipelineCommand {
     enum SCDType {
       SCD_TYPE_UNSPECIFIED = 0;
       SCD_TYPE_1 = 1;
+      SCD_TYPE_2 = 2;
     }
 
     // If true, define the flow as a one-time flow, such as for backfill.
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
index a7249ef70e49..ca41281a812b 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelinesHandler.scala
@@ -478,17 +478,47 @@ private[connect] object PipelinesHandler extends Logging {
       case proto.PipelineCommand.DefineFlow.SCDType.SCD_TYPE_1 |
           proto.PipelineCommand.DefineFlow.SCDType.SCD_TYPE_UNSPECIFIED =>
         ScdType.Type1
+      case proto.PipelineCommand.DefineFlow.SCDType.SCD_TYPE_2 =>
+        ScdType.Type2
       case other =>
         throw new UnsupportedOperationException(s"Unsupported AutoCDC SCD 
type: $other")
     }
 
+    // SCD2-only history-tracking column selection. Mirrors the 
column_list/except_column_list
+    // handling above: at most one side may be non-empty, and an empty 
selection means "track
+    // every eligible column" (None).
+    val trackHistorySelection: Option[ColumnSelection] = {
+      val included = autoCdcDetails.getTrackHistoryColumnListList.asScala.toSeq
+      val excluded = 
autoCdcDetails.getTrackHistoryExceptColumnListList.asScala.toSeq
+      if (included.nonEmpty && excluded.nonEmpty) {
+        throw new AnalysisException(
+          "AUTOCDC_BOTH_TRACK_HISTORY_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST",
+          Map.empty)
+      } else if (included.nonEmpty) {
+        
Some(ColumnSelection.IncludeColumns(included.map(asUnqualifiedColumnName)))
+      } else if (excluded.nonEmpty) {
+        
Some(ColumnSelection.ExcludeColumns(excluded.map(asUnqualifiedColumnName)))
+      } else {
+        None
+      }
+    }
+
+    // History-tracking columns only make sense under SCD2. The Python client 
rejects this
+    // client-side, but a raw Connect client can still send it, so mirror the 
check here to
+    // surface a clean user-facing error rather than the internal error the 
[[ChangeArgs]]
+    // guard would otherwise raise.
+    if (scdType != ScdType.Type2 && trackHistorySelection.isDefined) {
+      throw new AnalysisException("AUTOCDC_TRACK_HISTORY_REQUIRES_SCD2", 
Map.empty)
+    }
+
     val changeArgs = ChangeArgs(
       keys = keys,
       sequencing = toColumn(autoCdcDetails.getSequenceBy),
       storedAsScdType = scdType,
       deleteCondition =
         
Option.when(autoCdcDetails.hasApplyAsDeletes)(toColumn(autoCdcDetails.getApplyAsDeletes)),
-      columnSelection = columnSelection)
+      columnSelection = columnSelection,
+      trackHistorySelection = trackHistorySelection)
 
     AutoCdcFlow(
       identifier = flowIdentifier,
diff --git 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PythonPipelineSuite.scala
 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PythonPipelineSuite.scala
index 8eaf008c5809..c21a4a9ac25b 100644
--- 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PythonPipelineSuite.scala
+++ 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PythonPipelineSuite.scala
@@ -1268,10 +1268,10 @@ class PythonPipelineSuite
   }
 
   test("AutoCDC API: specifying both column_list and except_column_list is 
rejected") {
-    // The Python create_auto_cdc_flow API does not currently enforce the "at 
most one" contract
-    // client-side, so the proto carries both lists to the server, where the 
structured error is
-    // raised. If/when a Python-side check is added, this test guards against 
the server-side
-    // defense being silently bypassed.
+    // The Python create_auto_cdc_flow API rejects the "at most one" violation 
client-side with a
+    // CANNOT_SET_TOGETHER PySparkValueError, so the request never reaches the 
server. The
+    // server-side defense (AUTOCDC_BOTH_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST) 
is exercised directly
+    // against a raw proto in SparkDeclarativePipelinesServerSuite.
     val ex = intercept[RuntimeException] {
       buildGraph("""
           |@dp.table
@@ -1290,7 +1290,7 @@ class PythonPipelineSuite
           |)
           |""".stripMargin)
     }
-    
assert(ex.getMessage.contains("AUTOCDC_BOTH_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST"))
+    assert(ex.getMessage.contains("CANNOT_SET_TOGETHER"))
   }
 
   test("AutoCDC API: registered flow survives graph resolution and validation 
end-to-end") {
@@ -1323,6 +1323,106 @@ class PythonPipelineSuite
     assert(mergeFlow.changeArgs.storedAsScdType == ScdType.Type1)
   }
 
+  // SCD2 flows are only asserted at registration (via buildAutoCdcFlow) 
rather than through
+  // resolve().validate(): the engine's SCD2 execution path is still landing 
in a parallel
+  // workstream and rejects SCD2 during graph analysis. These tests pin the 
API-to-ChangeArgs
+  // contract those changes plug into.
+  test("AutoCDC API: SCD2 with track_history_column_list forwards to 
ChangeArgs") {
+    val flow = buildAutoCdcFlow("""
+        |@dp.table
+        |def src():
+        |  return spark.readStream.format("rate").load()
+        |
+        |dp.create_streaming_table("target")
+        |
+        |dp.create_auto_cdc_flow(
+        |    target = "target",
+        |    source = "src",
+        |    keys = ["value"],
+        |    sequence_by = "timestamp",
+        |    stored_as_scd_type = 2,
+        |    track_history_column_list = ["value"],
+        |)
+        |""".stripMargin)
+
+    assert(flow.changeArgs.storedAsScdType == ScdType.Type2)
+    assert(
+      flow.changeArgs.trackHistorySelection.contains(
+        ColumnSelection.IncludeColumns(Seq(UnqualifiedColumnName("value")))))
+  }
+
+  test("AutoCDC API: SCD2 with track_history_except_column_list forwards to 
ChangeArgs") {
+    val flow = buildAutoCdcFlow("""
+        |@dp.table
+        |def src():
+        |  return spark.readStream.format("rate").load()
+        |
+        |dp.create_streaming_table("target")
+        |
+        |dp.create_auto_cdc_flow(
+        |    target = "target",
+        |    source = "src",
+        |    keys = ["value"],
+        |    sequence_by = "timestamp",
+        |    stored_as_scd_type = 2,
+        |    track_history_except_column_list = ["timestamp"],
+        |)
+        |""".stripMargin)
+
+    assert(flow.changeArgs.storedAsScdType == ScdType.Type2)
+    assert(
+      flow.changeArgs.trackHistorySelection.contains(
+        
ColumnSelection.ExcludeColumns(Seq(UnqualifiedColumnName("timestamp")))))
+  }
+
+  test("AutoCDC API: SCD2 without track-history columns leaves selection 
unset") {
+    val flow = buildAutoCdcFlow("""
+        |@dp.table
+        |def src():
+        |  return spark.readStream.format("rate").load()
+        |
+        |dp.create_streaming_table("target")
+        |
+        |dp.create_auto_cdc_flow(
+        |    target = "target",
+        |    source = "src",
+        |    keys = ["value"],
+        |    sequence_by = "timestamp",
+        |    stored_as_scd_type = 2,
+        |)
+        |""".stripMargin)
+
+    assert(flow.changeArgs.storedAsScdType == ScdType.Type2)
+    assert(flow.changeArgs.trackHistorySelection.isEmpty)
+  }
+
+  test("AutoCDC API: specifying both track_history column lists is rejected") {
+    // The Python create_auto_cdc_flow API rejects the "at most one" violation 
client-side with a
+    // CANNOT_SET_TOGETHER PySparkValueError, so the request never reaches the 
server. The
+    // server-side defense 
(AUTOCDC_BOTH_TRACK_HISTORY_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST) is
+    // exercised directly against a raw proto in 
SparkDeclarativePipelinesServerSuite.
+    val ex = intercept[RuntimeException] {
+      buildGraph("""
+          |@dp.table
+          |def src():
+          |  return spark.readStream.format("rate").load()
+          |
+          |dp.create_streaming_table("target")
+          |
+          |dp.create_auto_cdc_flow(
+          |    target = "target",
+          |    source = "src",
+          |    keys = ["value"],
+          |    sequence_by = "timestamp",
+          |    stored_as_scd_type = 2,
+          |    track_history_column_list = ["value"],
+          |    track_history_except_column_list = ["timestamp"],
+          |)
+          |""".stripMargin)
+    }
+    assert(ex.getMessage.contains("CANNOT_SET_TOGETHER"))
+  }
+
   /**
    * Executes Python code in a separate process and returns the exit code.
    *
diff --git 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/SparkDeclarativePipelinesServerSuite.scala
 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/SparkDeclarativePipelinesServerSuite.scala
index 3cb45fa6e172..7ff8504a3e6a 100644
--- 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/SparkDeclarativePipelinesServerSuite.scala
+++ 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/SparkDeclarativePipelinesServerSuite.scala
@@ -115,6 +115,102 @@ class SparkDeclarativePipelinesServerSuite
     assert(ex.getMessage.contains("DEFINE_FLOW_ONCE_OPTION_NOT_SUPPORTED"))
   }
 
+  // The Python client rejects these AutoCDC misconfigurations client-side, so 
drive the raw
+  // Connect stub directly to exercise the server-side validation a non-Python 
client would hit.
+  private def unresolvedColumn(name: String): Expression =
+    Expression
+      .newBuilder()
+      .setUnresolvedAttribute(
+        
Expression.UnresolvedAttribute.newBuilder().setUnparsedIdentifier(name))
+      .build()
+
+  private def autoCdcDefineFlow(
+      graphId: String,
+      scdType: DefineFlow.SCDType,
+      columns: Seq[String] = Seq.empty,
+      exceptColumns: Seq[String] = Seq.empty,
+      trackHistoryColumns: Seq[String] = Seq.empty,
+      trackHistoryExceptColumns: Seq[String] = Seq.empty): DefineFlow = {
+    val autoCdcDetails = DefineFlow.AutoCdcFlowDetails
+      .newBuilder()
+      .setSource("src")
+      .addKeys(unresolvedColumn("id"))
+      .setSequenceBy(unresolvedColumn("ts"))
+      .setStoredAsScdType(scdType)
+    columns.foreach(c => autoCdcDetails.addColumnList(unresolvedColumn(c)))
+    exceptColumns.foreach(c => 
autoCdcDetails.addExceptColumnList(unresolvedColumn(c)))
+    trackHistoryColumns.foreach(c =>
+      autoCdcDetails.addTrackHistoryColumnList(unresolvedColumn(c)))
+    trackHistoryExceptColumns.foreach(c =>
+      autoCdcDetails.addTrackHistoryExceptColumnList(unresolvedColumn(c)))
+    DefineFlow
+      .newBuilder()
+      .setDataflowGraphId(graphId)
+      .setFlowName("target")
+      .setTargetDatasetName("target")
+      .setAutoCdcFlowDetails(autoCdcDetails)
+      .build()
+  }
+
+  test("AutoCDC: specifying both column_list and except_column_list is 
rejected server-side") {
+    val ex = intercept[Exception] {
+      withRawBlockingStub { implicit stub =>
+        val graphId = createDataflowGraph
+        sendPlan(
+          buildPlanFromPipelineCommand(
+            PipelineCommand
+              .newBuilder()
+              .setDefineFlow(
+                autoCdcDefineFlow(
+                  graphId,
+                  DefineFlow.SCDType.SCD_TYPE_1,
+                  columns = Seq("id"),
+                  exceptColumns = Seq("ts")))
+              .build()))
+      }
+    }
+    
assert(ex.getMessage.contains("AUTOCDC_BOTH_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST"))
+  }
+
+  test("AutoCDC: track-history columns without SCD2 are rejected server-side") 
{
+    val ex = intercept[Exception] {
+      withRawBlockingStub { implicit stub =>
+        val graphId = createDataflowGraph
+        sendPlan(
+          buildPlanFromPipelineCommand(
+            PipelineCommand
+              .newBuilder()
+              .setDefineFlow(autoCdcDefineFlow(
+                graphId,
+                DefineFlow.SCDType.SCD_TYPE_1,
+                trackHistoryColumns = Seq("val")))
+              .build()))
+      }
+    }
+    assert(ex.getMessage.contains("AUTOCDC_TRACK_HISTORY_REQUIRES_SCD2"))
+  }
+
+  test("AutoCDC: specifying both track-history column lists is rejected 
server-side") {
+    val ex = intercept[Exception] {
+      withRawBlockingStub { implicit stub =>
+        val graphId = createDataflowGraph
+        sendPlan(
+          buildPlanFromPipelineCommand(
+            PipelineCommand
+              .newBuilder()
+              .setDefineFlow(
+                autoCdcDefineFlow(
+                  graphId,
+                  DefineFlow.SCDType.SCD_TYPE_2,
+                  trackHistoryColumns = Seq("val"),
+                  trackHistoryExceptColumns = Seq("ts")))
+              .build()))
+      }
+    }
+    assert(
+      
ex.getMessage.contains("AUTOCDC_BOTH_TRACK_HISTORY_COLUMN_LIST_AND_EXCEPT_COLUMN_LIST"))
+  }
+
   test(
     "Cross dependency between SQL dataset and non-SQL dataset is valid and can 
be registered") {
     withRawBlockingStub { implicit stub =>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to