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

ahmedabu98 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 35ed6c70d80 Add YAML tests for writing to Iceberg CDC sink (#40542)
35ed6c70d80 is described below

commit 35ed6c70d80008f5560f3baeff7c45e192830487
Author: Ahmed Abualsaud <[email protected]>
AuthorDate: Wed Oct 14 14:26:06 2026 -0700

    Add YAML tests for writing to Iceberg CDC sink (#40542)
---
 .../beam_PostCommit_Yaml_Xlang_Direct.json         |   2 +-
 .../sdk/io/iceberg/cdc/sink/OrderedCommitFn.java   |   2 -
 .../beam/sdk/io/iceberg/cdc/sink/WriteCdcRows.java |   9 +-
 .../sdk/io/iceberg/cdc/sink/CommitDeltasTest.java  |   3 +-
 sdks/python/apache_beam/coders/row_coder_test.py   |  19 +++
 sdks/python/apache_beam/typehints/schemas.py       |  23 ++-
 .../e2e/delta_lake_cdc_to_iceberg.yaml             |  94 +++++++++++++
 .../extended_tests/e2e/iceberg_cdc_to_iceberg.yaml | 156 +++++++++++++++++++++
 sdks/python/apache_beam/yaml/integration_tests.py  |  34 +++++
 sdks/python/apache_beam/yaml/standard_io.yaml      |   1 +
 10 files changed, 332 insertions(+), 11 deletions(-)

diff --git a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json 
b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
index 2b9b913580a..a975cd1cd10 100644
--- a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
@@ -1,4 +1,4 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run",
-  "revision": 23
+  "revision": 1
 }
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/OrderedCommitFn.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/OrderedCommitFn.java
index 970910a9120..212e43c76df 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/OrderedCommitFn.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/OrderedCommitFn.java
@@ -157,7 +157,6 @@ class OrderedCommitFn extends DoFn<KV<String, 
WindowedCommit>, KV<String, Snapsh
     this.pendingSpec = StateSpecs.bag(CommitDeltas.windowedCommitCoder());
   }
 
-  @RequiresStableInput
   @ProcessElement
   public void process(
       @Element KV<String, WindowedCommit> element,
@@ -222,7 +221,6 @@ class OrderedCommitFn extends DoFn<KV<String, 
WindowedCommit>, KV<String, Snapsh
             + "continuation.");
   }
 
-  @RequiresStableInput
   @OnTimer("commit")
   public void onCommit(
       OnTimerContext c,
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteCdcRows.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteCdcRows.java
index a9ca1db6bc2..c6ab40108ca 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteCdcRows.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/sink/WriteCdcRows.java
@@ -81,10 +81,11 @@ import org.slf4j.LoggerFactory;
  *
  * <p>The sink commits one new snapshot to the destination table per commit 
window, in ascending
  * window order, with an idempotency token written to each snapshot's summary. 
A retried or
- * restarted commit finds the token and skips already-committed windows, 
making the commit
- * effectively-once on runners that honor {@code @RequiresStableInput}. 
Records whose grouped pane
- * fires late (watermark is past their commit window's end) are diverted to 
the DLQ output,
- * accessible with {@link IcebergWriteResult#getDeadLetterRows()}.
+ * restarted commit finds the token and skips already-committed windows, so 
each window commits
+ * once. The commit reads straight from a {@code GroupByKey}, so on runners 
that persist shuffle
+ * output, like Dataflow, a retried commit sees the same files. Records whose 
grouped pane fires
+ * late (watermark is past their commit window's end) are diverted to the DLQ 
output, accessible
+ * with {@link IcebergWriteResult#getDeadLetterRows()}.
  *
  * <p>Each dead-lettered record nests the data row under {@code record}, 
beside {@code change_type},
  * {@code sequence_number}, and {@code destination}.
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CommitDeltasTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CommitDeltasTest.java
index 38b38ca1b4d..892f1985af5 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CommitDeltasTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/sink/CommitDeltasTest.java
@@ -870,8 +870,7 @@ public class CommitDeltasTest {
    * An injected commit failure must carry operator-triage context and leave 
NO snapshot (strict
    * ascending order halts W1 behind the failing W0); a second run re-feeds 
the same inputs and
    * commits both windows, proving the failure left the files intact. (The 
DirectRunner fails fast
-   * rather than retrying {@code @RequiresStableInput} bundles, so the in-run 
retry is an IT
-   * concern.)
+   * rather than retrying bundles, so the in-run retry is an IT concern.)
    */
   @Test
   public void commitFailureCarriesTriageContextAndRetryRunCommitsInOrder() {
diff --git a/sdks/python/apache_beam/coders/row_coder_test.py 
b/sdks/python/apache_beam/coders/row_coder_test.py
index bd1307ff992..9bc6b23356b 100644
--- a/sdks/python/apache_beam/coders/row_coder_test.py
+++ b/sdks/python/apache_beam/coders/row_coder_test.py
@@ -282,6 +282,25 @@ class RowCoderTest(unittest.TestCase):
     for test_case in test_cases:
       self.assertEqual(test_case, coder.decode(coder.encode(test_case)))
 
+  def test_row_coder_underscore_field_name(self):
+    # NamedTuple rejects a leading underscore, as in the CDC metadata column
+    # _change_type.
+    schema = schema_pb2.Schema(
+        id="underscore_field_name",
+        fields=[
+            schema_pb2.Field(
+                name="id",
+                type=schema_pb2.FieldType(atomic_type=schema_pb2.INT64)),
+            schema_pb2.Field(
+                name="_change_type",
+                type=schema_pb2.FieldType(atomic_type=schema_pb2.STRING)),
+        ])
+    coder = RowCoder(schema)
+    row = named_tuple_from_schema(schema)(1, "INSERT")
+    self.assertEqual(row, coder.decode(coder.encode(row)))
+    self.assertEqual(row, pickler.loads(pickler.dumps(row)))
+    self.assertEqual({"id": 1, "_change_type": "INSERT"}, row._asdict())
+
   @unittest.skip(
       "https://github.com/apache/beam/issues/19696 - Overflow behavior in "
       "VarIntCoder is currently inconsistent")
diff --git a/sdks/python/apache_beam/typehints/schemas.py 
b/sdks/python/apache_beam/typehints/schemas.py
index 71bd540c522..9caccc762f7 100644
--- a/sdks/python/apache_beam/typehints/schemas.py
+++ b/sdks/python/apache_beam/typehints/schemas.py
@@ -71,6 +71,7 @@ any backwards-compatibility guarantee.
 # pytype: skip-file
 
 # ruff: noqa: UP006
+import dataclasses
 import datetime
 import decimal
 import logging
@@ -666,7 +667,16 @@ class SchemaTranslation(object):
     if schema.id in self.schema_registry.by_id:
       user_type = self.schema_registry.by_id[schema.id][0]
     else:
-      user_type = NamedTuple(type_name, subfields)
+      if any(name.startswith('_') for name, _ in subfields):
+        # NamedTuple rejects field names with a leading underscore, like the
+        # CDC metadata column _change_type.
+        user_type = dataclasses.make_dataclass(
+            type_name,
+            subfields,
+            namespace={'_asdict': _dataclass_asdict},
+            frozen=True)
+      else:
+        user_type = NamedTuple(type_name, subfields)
 
       # Define a reduce function, otherwise these types can't be pickled
       # (See BEAM-9574)
@@ -683,9 +693,18 @@ class SchemaTranslation(object):
     return user_type
 
 
+def _dataclass_asdict(self):
+  # Matches NamedTuple._asdict, so code written for NamedTuple rows still 
works.
+  return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
+
+
 def _named_tuple_reduce_method(serialized_schema):
   def __reduce__(self):
-    return _hydrate_namedtuple_instance, (serialized_schema, tuple(self))
+    if dataclasses.is_dataclass(self):
+      values = tuple(getattr(self, f.name) for f in dataclasses.fields(self))
+    else:
+      values = tuple(self)
+    return _hydrate_namedtuple_instance, (serialized_schema, values)
 
   return __reduce__
 
diff --git 
a/sdks/python/apache_beam/yaml/extended_tests/e2e/delta_lake_cdc_to_iceberg.yaml
 
b/sdks/python/apache_beam/yaml/extended_tests/e2e/delta_lake_cdc_to_iceberg.yaml
new file mode 100644
index 00000000000..642ef4c1ecc
--- /dev/null
+++ 
b/sdks/python/apache_beam/yaml/extended_tests/e2e/delta_lake_cdc_to_iceberg.yaml
@@ -0,0 +1,94 @@
+#
+# 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.
+#
+
+fixtures:
+  - name: DELTA_TABLE
+    type: 
"apache_beam.yaml.integration_tests.temp_delta_cdc_table_with_updates"
+  - name: TEMP_DIR
+    type: "tempfile.TemporaryDirectory"
+
+pipelines:
+  # Applies the Delta Lake table's changes in two batch syncs: version 0 (three
+  # new orders), then versions 1 to 3 (an update, a delete and a new order). 
The
+  # second sync changes rows that the first one committed.
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromDeltaCDC
+          config:
+            table: "{DELTA_TABLE}"
+            start_version: 0
+            end_version: 0
+            include_metadata_columns: ["_change_type", "_commit_version"]
+        - type: WriteToIceberg
+          config:
+            table: "shop.orders"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            mode: "merge-on-read"
+            equality_columns: ["id"]
+            change_type_column: "_change_type"
+            change_type_map:
+              insert: "INSERT"
+              update_preimage: "UPDATE_BEFORE"
+              update_postimage: "UPDATE_AFTER"
+              delete: "DELETE"
+            sequence_number_column: "_commit_version"
+
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromDeltaCDC
+          config:
+            table: "{DELTA_TABLE}"
+            start_version: 1
+            include_metadata_columns: ["_change_type", "_commit_version"]
+        - type: WriteToIceberg
+          config:
+            table: "shop.orders"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            mode: "merge-on-read"
+            equality_columns: ["id"]
+            change_type_column: "_change_type"
+            change_type_map:
+              insert: "INSERT"
+              update_preimage: "UPDATE_BEFORE"
+              update_postimage: "UPDATE_AFTER"
+              delete: "DELETE"
+            sequence_number_column: "_commit_version"
+
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromIceberg
+          config:
+            table: "shop.orders"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+        - type: AssertEqual
+          config:
+            elements:
+              - {id: 1, item: "apple", qty: 4}
+              - {id: 3, item: "plum", qty: 2}
+              - {id: 4, item: "kiwi", qty: 1}
diff --git 
a/sdks/python/apache_beam/yaml/extended_tests/e2e/iceberg_cdc_to_iceberg.yaml 
b/sdks/python/apache_beam/yaml/extended_tests/e2e/iceberg_cdc_to_iceberg.yaml
new file mode 100644
index 00000000000..1e916788bbb
--- /dev/null
+++ 
b/sdks/python/apache_beam/yaml/extended_tests/e2e/iceberg_cdc_to_iceberg.yaml
@@ -0,0 +1,156 @@
+#
+# 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.
+#
+
+fixtures:
+  - name: TEMP_DIR
+    type: "tempfile.TemporaryDirectory"
+
+pipelines:
+  # Gives shop.orders two snapshots: three new orders, then an update, a delete
+  # and a new order. Each row carries its change type (op) and sequence number
+  # (seq).
+  - pipeline:
+      type: chain
+      transforms:
+        - type: Create
+          config:
+            elements:
+              - {id: 1, item: "apple", qty: 3, op: "INSERT", seq: 1}
+              - {id: 2, item: "pear", qty: 5, op: "INSERT", seq: 1}
+              - {id: 3, item: "plum", qty: 2, op: "INSERT", seq: 1}
+        - type: WriteToIceberg
+          config:
+            table: "shop.orders"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            mode: "merge-on-read"
+            equality_columns: ["id"]
+            change_type_column: "op"
+            sequence_number_column: "seq"
+
+  - pipeline:
+      type: chain
+      transforms:
+        - type: Create
+          config:
+            elements:
+              - {id: 1, item: "apple", qty: 3, op: "UPDATE_BEFORE", seq: 2}
+              - {id: 1, item: "apple", qty: 4, op: "UPDATE_AFTER", seq: 2}
+              - {id: 2, item: "pear", qty: 5, op: "DELETE", seq: 2}
+              - {id: 4, item: "kiwi", qty: 1, op: "INSERT", seq: 2}
+        - type: WriteToIceberg
+          config:
+            table: "shop.orders"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            mode: "merge-on-read"
+            equality_columns: ["id"]
+            change_type_column: "op"
+            sequence_number_column: "seq"
+
+  # Batch: replays every change in shop.orders into shop.orders_mirror.
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromIcebergCDC
+          config:
+            table: "shop.orders"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            include_metadata_columns:
+              ["_change_type", "_commit_snapshot_sequence_number"]
+        - type: WriteToIceberg
+          config:
+            table: "shop.orders_mirror"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            mode: "merge-on-read"
+            equality_columns: ["id"]
+            change_type_column: "_change_type"
+
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromIceberg
+          config:
+            table: "shop.orders_mirror"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+        - type: AssertEqual
+          config:
+            elements:
+              - {id: 1, item: "apple", qty: 4}
+              - {id: 3, item: "plum", qty: 2}
+              - {id: 4, item: "kiwi", qty: 1}
+
+  # Streaming: replays the same changes into shop.orders_streaming_mirror. The
+  # far-future to_timestamp stops the stream at the latest snapshot. One-second
+  # commit windows put the two snapshots in separate commits, so the second
+  # commit updates and deletes rows that the first one wrote.
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromIcebergCDC
+          config:
+            table: "shop.orders"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            streaming: true
+            starting_strategy: "earliest"
+            to_timestamp: 4102444800000
+            include_metadata_columns:
+              ["_change_type", "_commit_snapshot_sequence_number"]
+        - type: WriteToIceberg
+          config:
+            table: "shop.orders_streaming_mirror"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+            mode: "merge-on-read"
+            equality_columns: ["id"]
+            change_type_column: "_change_type"
+            triggering_frequency_seconds: 1
+
+  - pipeline:
+      type: chain
+      transforms:
+        - type: ReadFromIceberg
+          config:
+            table: "shop.orders_streaming_mirror"
+            catalog_name: "hadoop_catalog"
+            catalog_properties:
+              type: "hadoop"
+              warehouse: "{TEMP_DIR}"
+        - type: AssertEqual
+          config:
+            elements:
+              - {id: 1, item: "apple", qty: 4}
+              - {id: 3, item: "plum", qty: 2}
+              - {id: 4, item: "kiwi", qty: 1}
diff --git a/sdks/python/apache_beam/yaml/integration_tests.py 
b/sdks/python/apache_beam/yaml/integration_tests.py
index becfaa79303..107d55df19b 100644
--- a/sdks/python/apache_beam/yaml/integration_tests.py
+++ b/sdks/python/apache_beam/yaml/integration_tests.py
@@ -1279,6 +1279,40 @@ def temp_delta_cdc_table():
     yield temp_dir
 
 
[email protected]
+def temp_delta_cdc_table_with_updates():
+  try:
+    from deltalake import DeltaTable
+    from deltalake import write_deltalake
+  except ImportError as exn:
+    raise unittest.SkipTest('deltalake is not installed') from exn
+
+  # The CDC sink needs a non-null key.
+  schema = pa.schema([
+      pa.field("id", pa.int64(), nullable=False),
+      pa.field("item", pa.string()),
+      pa.field("qty", pa.int64()),
+  ])
+
+  def orders(*rows):
+    return pa.Table.from_pylist([dict(zip(schema.names, row)) for row in rows],
+                                schema=schema)
+
+  with tempfile.TemporaryDirectory() as temp_dir:
+    # Version 0: three new orders.
+    write_deltalake(
+        temp_dir,
+        orders((1, "apple", 3), (2, "pear", 5), (3, "plum", 2)),
+        configuration={"delta.enableChangeDataFeed": "true"})
+    # Versions 1 to 3: update order 1, delete order 2, add order 4.
+    table = DeltaTable(temp_dir)
+    table.update(updates={"qty": "4"}, predicate="id = 1")
+    table.delete("id = 2")
+    write_deltalake(temp_dir, orders((4, "kiwi", 1)), mode="append")
+
+    yield temp_dir
+
+
 def replace_recursive(spec, vars):
   """Recursively replaces string placeholders in a spec with values from vars.
 
diff --git a/sdks/python/apache_beam/yaml/standard_io.yaml 
b/sdks/python/apache_beam/yaml/standard_io.yaml
index 5176907d2fc..9dc3eff38b1 100644
--- a/sdks/python/apache_beam/yaml/standard_io.yaml
+++ b/sdks/python/apache_beam/yaml/standard_io.yaml
@@ -671,6 +671,7 @@
         filter: 'filter'
         from_snapshot: 'from_snapshot'
         from_timestamp: 'from_timestamp'
+        include_metadata_columns: 'include_metadata_columns'
         keep: 'keep'
         poll_interval_seconds: 'poll_interval_seconds'
         starting_strategy: 'starting_strategy'

Reply via email to