This is an automated email from the ASF dual-hosted git repository.
chamikaramj 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 2a4f4c7fc5e Adds Delta Lake bounded CDC read support to Beam Python
and YAML SDKs (#40085)
2a4f4c7fc5e is described below
commit 2a4f4c7fc5ef0c184fe1b5808724812322689113
Author: Chamikara Jayalath <[email protected]>
AuthorDate: Tue Sep 15 09:12:21 2026 -0700
Adds Delta Lake bounded CDC read support to Beam Python and YAML SDKs
(#40085)
---
.../beam_PostCommit_Java_Delta_IO_Dataflow.json | 2 +-
.../beam_PostCommit_Python_Xlang_IO_Dataflow.json | 2 +-
.../beam_PostCommit_Python_Xlang_IO_Direct.json | 2 +-
.../beam_PostCommit_Yaml_Xlang_Direct.json | 2 +-
.../DeltaCdcReadSchemaTransformProviderTest.java | 161 ++++++++++++++++++
sdks/python/apache_beam/transforms/external.py | 1 +
sdks/python/apache_beam/transforms/managed.py | 12 +-
.../transforms/managed_delta_it_test.py | 189 +++++++++++++++++++++
sdks/python/apache_beam/transforms/managed_test.py | 64 +++++++
sdks/python/apache_beam/yaml/integration_tests.py | 23 +++
sdks/python/apache_beam/yaml/standard_io.yaml | 1 +
sdks/python/apache_beam/yaml/tests/delta_cdc.yaml | 47 +++++
sdks/python/apache_beam/yaml/yaml_io.py | 37 ++++
sdks/python/apache_beam/yaml/yaml_io_test.py | 19 +++
sdks/python/setup.py | 3 +-
15 files changed, 559 insertions(+), 6 deletions(-)
diff --git a/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
b/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
index 0ca37f8c8e2..9a4fbff0c0b 100644
--- a/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
+++ b/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
@@ -1,5 +1,5 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run.",
- "modification": 1,
+ "modification": 2,
"https://github.com/apache/beam/pull/39990": "removing dead code from
FnApiDoFnRunner"
}
diff --git
a/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Dataflow.json
b/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Dataflow.json
index 4f9719d7185..e3ce593c117 100644
--- a/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Dataflow.json
+++ b/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Dataflow.json
@@ -1,5 +1,5 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run",
- "modification": 3,
+ "modification": 5,
"https://github.com/apache/beam/pull/39990": "removing dead code from
FnApiDoFnRunner"
}
diff --git a/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.json
b/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.json
index 1b1ef86e917..8b8cd389b3d 100644
--- a/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_Python_Xlang_IO_Direct.json
@@ -1,5 +1,5 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run",
- "modification": 2,
+ "modification": 4,
"https://github.com/apache/beam/pull/39990": "removing dead code from
FnApiDoFnRunner"
}
diff --git a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json
index 86bf1193abd..8ed972c9f57 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": 7
+ "revision": 3
}
diff --git
a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaCdcReadSchemaTransformProviderTest.java
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaCdcReadSchemaTransformProviderTest.java
new file mode 100644
index 00000000000..b82bb97cf6f
--- /dev/null
+++
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaCdcReadSchemaTransformProviderTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.beam.sdk.io.delta;
+
+import static
org.apache.beam.sdk.io.delta.DeltaCdcReadSchemaTransformProvider.Configuration;
+import static
org.apache.beam.sdk.io.delta.DeltaCdcReadSchemaTransformProvider.OUTPUT_TAG;
+
+import io.delta.kernel.defaults.engine.DefaultEngine;
+import io.delta.kernel.engine.Engine;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.beam.sdk.extensions.avro.coders.AvroCoder;
+import org.apache.beam.sdk.extensions.avro.schemas.utils.AvroUtils;
+import org.apache.beam.sdk.io.Compression;
+import org.apache.beam.sdk.io.FileIO;
+import org.apache.beam.sdk.io.parquet.ParquetIO;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.transforms.windowing.PaneInfo;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.Row;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link DeltaCdcReadSchemaTransformProvider}. */
+@RunWith(JUnit4.class)
+public class DeltaCdcReadSchemaTransformProviderTest {
+
+ @Rule public TestPipeline writePipeline = TestPipeline.create();
+ @Rule public TestPipeline readPipeline = TestPipeline.create();
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ @Test
+ public void testBuildTransformWithRow() {
+ Map<String, String> hadoopConfig = new HashMap<>();
+ hadoopConfig.put("fs.gs.project.id", "test-project");
+
+ Row config =
+ Row.withSchema(new
DeltaCdcReadSchemaTransformProvider().configurationSchema())
+ .withFieldValue("table", "/path/to/table")
+ .withFieldValue("start_version", 0L)
+ .withFieldValue("end_version", 5L)
+ .withFieldValue("hadoop_config", hadoopConfig)
+ .withFieldValue("include_metadata_columns",
Arrays.asList(DeltaIO.CHANGE_TYPE_COLUMN))
+ .build();
+
+ new DeltaCdcReadSchemaTransformProvider().from(config);
+ }
+
+ @Test
+ public void testSimpleScan() throws Exception {
+ File tableDir = tempFolder.newFolder("delta-table-cdc-simple");
+
+ // 1. Write a Parquet file using Beam
+ Schema schema = Schema.builder().addField("name",
Schema.FieldType.STRING).build();
+ Row row = Row.withSchema(schema).addValues("test-name").build();
+
+ org.apache.avro.Schema avroSchema = AvroUtils.toAvroSchema(schema);
+ GenericRecord record = AvroUtils.toGenericRecord(row, avroSchema);
+
+ writePipeline
+ .apply("Create Input",
Create.of(record).withCoder(AvroCoder.of(avroSchema)))
+ .apply(
+ "Write Parquet",
+ FileIO.<GenericRecord>write()
+ .via(ParquetIO.sink(avroSchema))
+ .to(tableDir.getAbsolutePath() + "/")
+ .withNaming(
+ (BoundedWindow window,
+ PaneInfo paneInfo,
+ int numShards,
+ int shardIndex,
+ Compression compression) -> "part-00000.parquet"));
+
+ writePipeline.run().waitUntilFinish();
+
+ File parquetFile = new File(tableDir, "part-00000.parquet");
+ byte[] fileBytes = Files.readAllBytes(parquetFile.toPath());
+
+ // 2. Create the Delta log with CDF enabled
+ File logDir = new File(tableDir, "_delta_log");
+ logDir.mkdirs();
+ File commitFile = new File(logDir, "00000000000000000000.json");
+
+ String commitContent =
+ "{\"protocol\":{\"minReaderVersion\":1,\"minWriterVersion\":2}}\n"
+ +
"{\"metaData\":{\"id\":\"test-id\",\"format\":{\"provider\":\"parquet\",\"options\":{}},\"schemaString\":\"{\\\"type\\\":\\\"struct\\\",\\\"fields\\\":[{\\\"name\\\":\\\"name\\\",\\\"type\\\":\\\"string\\\",\\\"nullable\\\":true,\\\"metadata\\\":{}}]}\",\"partitionColumns\":[],\"configuration\":{\"delta.enableChangeDataFeed\":\"true\"},\"createdAt\":123456789}}\n"
+ +
"{\"add\":{\"path\":\"part-00000.parquet\",\"partitionValues\":{},\"size\":"
+ + fileBytes.length
+ + ",\"modificationTime\":123456789,\"dataChange\":true}}";
+
+ Files.write(commitFile.toPath(),
commitContent.getBytes(StandardCharsets.UTF_8));
+
+ // 3. Read it using DeltaCdcReadSchemaTransformProvider
+ Configuration readConfig =
+
Configuration.builder().setTable(tableDir.getAbsolutePath()).setStartVersion(0L).build();
+
+ PCollection<Row> output =
+ PCollectionRowTuple.empty(readPipeline)
+ .apply(new DeltaCdcReadSchemaTransformProvider().from(readConfig))
+ .get(OUTPUT_TAG);
+
+ PAssert.that(output).containsInAnyOrder(row);
+
+ readPipeline.run().waitUntilFinish();
+ }
+
+ @Test
+ public void testReadWithStartVersion() throws Exception {
+ File tableDir = tempFolder.newFolder("delta-table-cdc-version");
+ Engine engine = DefaultEngine.create(new
org.apache.hadoop.conf.Configuration());
+
+ List<Row> rows = DeltaWriteTestUtils.setupTwoVersionTable(engine,
tableDir.getAbsolutePath());
+ Row row1 = rows.get(0);
+ Row row2 = rows.get(1);
+
+ Configuration readConfig =
+ Configuration.builder()
+ .setTable(tableDir.getAbsolutePath())
+ .setStartVersion(0L)
+ .setEndVersion(0L)
+ .build();
+
+ PCollection<Row> output =
+ PCollectionRowTuple.empty(readPipeline)
+ .apply(new DeltaCdcReadSchemaTransformProvider().from(readConfig))
+ .get(OUTPUT_TAG);
+
+ PAssert.that(output).containsInAnyOrder(row1, row2);
+
+ readPipeline.run().waitUntilFinish();
+ }
+}
diff --git a/sdks/python/apache_beam/transforms/external.py
b/sdks/python/apache_beam/transforms/external.py
index 90de7aed24a..33926281074 100644
--- a/sdks/python/apache_beam/transforms/external.py
+++ b/sdks/python/apache_beam/transforms/external.py
@@ -87,6 +87,7 @@ MANAGED_TRANSFORM_URN_TO_JAR_TARGET_MAPPING = {
ManagedTransforms.Urns.SQL_SERVER_READ.urn:
_GCP_EXPANSION_SERVICE_JAR_TARGET, # pylint: disable=line-too-long
ManagedTransforms.Urns.SQL_SERVER_WRITE.urn:
_GCP_EXPANSION_SERVICE_JAR_TARGET, # pylint: disable=line-too-long
ManagedTransforms.Urns.DELTA_LAKE_READ.urn:
_IO_EXPANSION_SERVICE_JAR_TARGET,
+ ManagedTransforms.Urns.DELTA_LAKE_CDC_READ.urn:
_IO_EXPANSION_SERVICE_JAR_TARGET,
}
diff --git a/sdks/python/apache_beam/transforms/managed.py
b/sdks/python/apache_beam/transforms/managed.py
index ba4cb38a011..c252b10de5d 100644
--- a/sdks/python/apache_beam/transforms/managed.py
+++ b/sdks/python/apache_beam/transforms/managed.py
@@ -89,8 +89,17 @@ POSTGRES = "postgres"
MYSQL = "mysql"
SQL_SERVER = "sqlserver"
DELTA = "delta"
+DELTA_CDC = "delta_cdc"
-__all__ = ["ICEBERG", "KAFKA", "BIGQUERY", "DELTA", "Read", "Write"]
+__all__ = [
+ "ICEBERG",
+ "KAFKA",
+ "BIGQUERY",
+ "DELTA",
+ "DELTA_CDC",
+ "Read",
+ "Write",
+]
class Read(PTransform):
@@ -104,6 +113,7 @@ class Read(PTransform):
MYSQL: ManagedTransforms.Urns.MYSQL_READ.urn,
SQL_SERVER: ManagedTransforms.Urns.SQL_SERVER_READ.urn,
DELTA: ManagedTransforms.Urns.DELTA_LAKE_READ.urn,
+ DELTA_CDC: ManagedTransforms.Urns.DELTA_LAKE_CDC_READ.urn,
}
def __init__(
diff --git a/sdks/python/apache_beam/transforms/managed_delta_it_test.py
b/sdks/python/apache_beam/transforms/managed_delta_it_test.py
new file mode 100644
index 00000000000..59446a65ab9
--- /dev/null
+++ b/sdks/python/apache_beam/transforms/managed_delta_it_test.py
@@ -0,0 +1,189 @@
+#
+# 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.
+#
+
+"""Integration tests for DeltaIO and Delta CDC using Managed Transforms."""
+
+import logging
+import os
+import shutil
+import tempfile
+import unittest
+import uuid
+
+import pyarrow as pa
+import pytest
+
+# pylint: disable=wrong-import-order, wrong-import-position, ungrouped-imports
+try:
+ from deltalake import write_deltalake
+except ImportError:
+ write_deltalake = None
+# pylint: enable=wrong-import-order, wrong-import-position, ungrouped-imports
+
+import apache_beam as beam
+from apache_beam.io.filesystem import CompressionTypes
+from apache_beam.io.filesystems import FileSystems
+from apache_beam.options.pipeline_options import StandardOptions
+from apache_beam.testing.test_pipeline import TestPipeline
+from apache_beam.testing.util import assert_that
+from apache_beam.testing.util import equal_to
+
+_LOGGER = logging.getLogger(__name__)
+
+# GCS location used to stage the Delta table when the pipeline runs on a remote
+# runner. Beam's integration test suites have read/write access to this bucket.
+_REMOTE_TABLE_ROOT = 'gs://temp-storage-for-end-to-end-tests/managed_delta_it'
+
+
[email protected]_io_java_expansion_service
[email protected](
+ os.environ.get('EXPANSION_JARS'),
+ "EXPANSION_JARS environment var is not provided, "
+ "indicating that jars have not been built")
[email protected](write_deltalake is None, 'deltalake is not installed.')
+class ManagedDeltaIT(unittest.TestCase):
+ # The table is written once and shared by all test methods, since every test
+ # only reads from it.
+ test_pipeline = None
+ args = None
+ # Path handed to the Managed transform; either `local_dir` or `staged_table`.
+ table = None
+ local_dir = None
+ staged_table = None
+
+ @classmethod
+ def setUpClass(cls):
+ cls.test_pipeline = TestPipeline(is_integration_test=True)
+ cls.args = cls.test_pipeline.get_full_options_as_args()
+
+ cls.local_dir = tempfile.mkdtemp()
+ cls._write_table(cls.local_dir)
+
+ # The Delta table is read by the runner's workers, so a local path only
+ # works when those workers run on this machine (e.g. DirectRunner). For
+ # remote runners the table has to be staged somewhere they can reach.
+ if cls._is_remote_runner():
+ cls.staged_table = FileSystems.join(_REMOTE_TABLE_ROOT, uuid.uuid4().hex)
+ _LOGGER.info('Staging Delta table at %s', cls.staged_table)
+ cls._upload_table(cls.local_dir, cls.staged_table)
+ cls.table = cls.staged_table
+ else:
+ cls.table = cls.local_dir
+
+ @classmethod
+ def tearDownClass(cls):
+ if cls.local_dir:
+ shutil.rmtree(cls.local_dir, ignore_errors=True)
+ if cls.staged_table:
+ try:
+ paths = [
+ metadata.path
+ for match_result in FileSystems.match([cls.staged_table + '/**'])
+ for metadata in match_result.metadata_list
+ ]
+ if paths:
+ FileSystems.delete(paths)
+ except Exception: # pylint: disable=broad-except
+ # Not fatal: the staged table is a handful of small files under a
+ # unique prefix in a shared test bucket.
+ _LOGGER.warning(
+ 'Failed to clean up staged Delta table at %s',
+ cls.staged_table,
+ exc_info=True)
+
+ @classmethod
+ def _is_remote_runner(cls):
+ runner = cls.test_pipeline.get_pipeline_options().view_as(
+ StandardOptions).runner or ''
+ return 'dataflow' in runner.lower()
+
+ @staticmethod
+ def _write_table(path):
+ """Writes a two-version Delta table with change data feed enabled."""
+ # Version 0 commit
+ write_deltalake(
+ path,
+ pa.table({"name": ["a", "b"]}),
+ mode="overwrite",
+ configuration={"delta.enableChangeDataFeed": "true"})
+
+ # Version 1 commit
+ write_deltalake(path, pa.table({"name": ["c"]}), mode="append")
+
+ @staticmethod
+ def _upload_table(local_dir, dest_dir):
+ """Copies a local Delta table directory to ``dest_dir``.
+
+ Delta transaction logs reference data files using paths relative to the
+ table root, so a verbatim copy of the directory is itself a valid table.
+ """
+ for root, _, files in os.walk(local_dir):
+ for name in files:
+ local_path = os.path.join(root, name)
+ relative_path = os.path.relpath(local_path, local_dir)
+ dest_path = FileSystems.join(dest_dir, *relative_path.split(os.sep))
+ with open(local_path, 'rb') as src, FileSystems.create(
+ dest_path, compression_type=CompressionTypes.UNCOMPRESSED) as dest:
+ shutil.copyfileobj(src, dest)
+
+ def test_read_delta(self):
+ with beam.Pipeline(argv=self.args) as p:
+ output = (
+ p
+ | beam.managed.Read(beam.managed.DELTA, config={"table": self.table})
+ | beam.Map(lambda row: row.name))
+ assert_that(output, equal_to(["a", "b", "c"]))
+
+ def test_read_delta_cdc_all_versions(self):
+ with beam.Pipeline(argv=self.args) as p:
+ output = (
+ p
+ | beam.managed.Read(
+ beam.managed.DELTA_CDC,
+ config={
+ "table": self.table, "start_version": 0
+ })
+ | beam.Map(lambda row: row.name))
+ assert_that(output, equal_to(["a", "b", "c"]))
+
+ def test_read_delta_cdc_from_version_1(self):
+ with beam.Pipeline(argv=self.args) as p:
+ output = (
+ p
+ | beam.managed.Read(
+ beam.managed.DELTA_CDC,
+ config={
+ "table": self.table, "start_version": 1
+ })
+ | beam.Map(lambda row: row.name))
+ assert_that(output, equal_to(["c"]))
+
+ def test_read_delta_cdc_version_range(self):
+ with beam.Pipeline(argv=self.args) as p:
+ output = (
+ p
+ | beam.managed.Read(
+ beam.managed.DELTA_CDC,
+ config={
+ "table": self.table, "start_version": 0, "end_version": 0
+ })
+ | beam.Map(lambda row: row.name))
+ assert_that(output, equal_to(["a", "b"]))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/sdks/python/apache_beam/transforms/managed_test.py
b/sdks/python/apache_beam/transforms/managed_test.py
new file mode 100644
index 00000000000..0eda998e744
--- /dev/null
+++ b/sdks/python/apache_beam/transforms/managed_test.py
@@ -0,0 +1,64 @@
+#
+# 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.
+#
+
+"""Unit tests for the managed transforms module."""
+
+import unittest
+
+import apache_beam as beam
+from apache_beam.portability.common_urns import ManagedTransforms
+from apache_beam.transforms.external import
MANAGED_TRANSFORM_URN_TO_JAR_TARGET_MAPPING
+from apache_beam.transforms.managed import DELTA
+from apache_beam.transforms.managed import DELTA_CDC
+from apache_beam.transforms.managed import Read
+
+
+class ManagedTest(unittest.TestCase):
+ def test_delta_constants(self):
+ self.assertEqual(DELTA, "delta")
+ self.assertEqual(DELTA_CDC, "delta_cdc")
+ self.assertIn("DELTA", beam.managed.__all__)
+ self.assertIn("DELTA_CDC", beam.managed.__all__)
+
+ def test_read_delta_transforms(self):
+ read_delta = Read(DELTA, config={"table": "test_table"})
+ self.assertEqual(read_delta._source, "delta")
+ self.assertEqual(
+ read_delta._underlying_identifier,
+ ManagedTransforms.Urns.DELTA_LAKE_READ.urn)
+
+ read_delta_cdc = Read(DELTA_CDC, config={"table": "test_table"})
+ self.assertEqual(read_delta_cdc._source, "delta_cdc")
+ self.assertEqual(
+ read_delta_cdc._underlying_identifier,
+ ManagedTransforms.Urns.DELTA_LAKE_CDC_READ.urn)
+
+ def test_invalid_source_raises(self):
+ with self.assertRaises(ValueError):
+ Read("unsupported_source", config={})
+
+ def test_expansion_service_resolution(self):
+ self.assertIn(
+ ManagedTransforms.Urns.DELTA_LAKE_READ.urn,
+ MANAGED_TRANSFORM_URN_TO_JAR_TARGET_MAPPING)
+ self.assertIn(
+ ManagedTransforms.Urns.DELTA_LAKE_CDC_READ.urn,
+ MANAGED_TRANSFORM_URN_TO_JAR_TARGET_MAPPING)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/sdks/python/apache_beam/yaml/integration_tests.py
b/sdks/python/apache_beam/yaml/integration_tests.py
index 71a2c3770c9..2ad64c40435 100644
--- a/sdks/python/apache_beam/yaml/integration_tests.py
+++ b/sdks/python/apache_beam/yaml/integration_tests.py
@@ -1137,6 +1137,29 @@ def temp_delta_table():
yield temp_dir
[email protected]
+def temp_delta_cdc_table():
+ try:
+ from deltalake import write_deltalake
+ except ImportError as exn:
+ raise unittest.SkipTest('deltalake is not installed') from exn
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ # Version 0 commit
+ table_data = pa.table({"name": ["a", "b"]})
+ write_deltalake(
+ temp_dir,
+ table_data,
+ mode="overwrite",
+ configuration={"delta.enableChangeDataFeed": "true"})
+
+ # Version 1 commit
+ table_data_1 = pa.table({"name": ["c"]})
+ write_deltalake(temp_dir, table_data_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 173236dc5e9..0ba02d1fdda 100644
--- a/sdks/python/apache_beam/yaml/standard_io.yaml
+++ b/sdks/python/apache_beam/yaml/standard_io.yaml
@@ -118,6 +118,7 @@
'ReadFromMongoDB': 'apache_beam.yaml.yaml_io.read_from_mongodb'
'WriteToMongoDB': 'apache_beam.yaml.yaml_io.write_to_mongodb'
'ReadFromDelta': 'apache_beam.yaml.yaml_io.read_from_delta'
+ 'ReadFromDeltaCDC': 'apache_beam.yaml.yaml_io.read_from_delta_cdc'
'DicomSearch': 'apache_beam.yaml.yaml_io.dicom_search'
# General File Formats
diff --git a/sdks/python/apache_beam/yaml/tests/delta_cdc.yaml
b/sdks/python/apache_beam/yaml/tests/delta_cdc.yaml
new file mode 100644
index 00000000000..319403c6d10
--- /dev/null
+++ b/sdks/python/apache_beam/yaml/tests/delta_cdc.yaml
@@ -0,0 +1,47 @@
+#
+# 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_CDC_TABLE
+ type: "apache_beam.yaml.integration_tests.temp_delta_cdc_table"
+
+pipelines:
+ - pipeline:
+ type: chain
+ transforms:
+ - type: ReadFromDeltaCDC
+ config:
+ table: "{DELTA_CDC_TABLE}"
+ start_version: 0
+ - type: AssertEqual
+ config:
+ elements:
+ - {name: "a"}
+ - {name: "b"}
+ - {name: "c"}
+
+ - pipeline:
+ type: chain
+ transforms:
+ - type: ReadFromDeltaCDC
+ config:
+ table: "{DELTA_CDC_TABLE}"
+ start_version: 1
+ - type: AssertEqual
+ config:
+ elements:
+ - {name: "c"}
diff --git a/sdks/python/apache_beam/yaml/yaml_io.py
b/sdks/python/apache_beam/yaml/yaml_io.py
index 47d2f4685e4..b7ce98b3599 100644
--- a/sdks/python/apache_beam/yaml/yaml_io.py
+++ b/sdks/python/apache_beam/yaml/yaml_io.py
@@ -601,6 +601,43 @@ def read_from_delta(
hadoop_config=hadoop_config))
+def read_from_delta_cdc(
+ table: str,
+ start_version: Optional[int] = None,
+ start_timestamp: Optional[str] = None,
+ end_version: Optional[int] = None,
+ end_timestamp: Optional[str] = None,
+ include_metadata_columns: Optional[Iterable[str]] = None,
+ hadoop_config: Optional[Mapping[str, str]] = None,
+):
+ """Reads change records from a Delta Lake table.
+
+ Args:
+ table: Identifier of the Delta Lake table.
+ start_version: Start version of the Delta Lake table to read changes from.
+ Either this or start_timestamp has to be provided.
+ start_timestamp: Start timestamp of the Delta Lake table to read changes
+ from. Should be specified in the ISO 8601 standard. Either this or
+ start_version has to be provided.
+ end_version: End version of the Delta Lake table to read changes up to.
+ end_timestamp: End timestamp of the Delta Lake table to read changes up to.
+ Should be specified in the ISO 8601 standard.
+ include_metadata_columns: Metadata columns to include in the output rows.
+ Supported columns are: _change_type, _commit_version, and
_commit_timestamp.
+ hadoop_config: Properties passed to the Hadoop Configuration.
+ """
+ return beam.managed.Read(
+ "delta_cdc",
+ config=dict(
+ table=table,
+ start_version=start_version,
+ start_timestamp=start_timestamp,
+ end_version=end_version,
+ end_timestamp=end_timestamp,
+ include_metadata_columns=include_metadata_columns,
+ hadoop_config=hadoop_config))
+
+
def write_to_iceberg(
table: str,
catalog_name: Optional[str] = None,
diff --git a/sdks/python/apache_beam/yaml/yaml_io_test.py
b/sdks/python/apache_beam/yaml/yaml_io_test.py
index 6f982be6f5b..7276aa5f072 100644
--- a/sdks/python/apache_beam/yaml/yaml_io_test.py
+++ b/sdks/python/apache_beam/yaml/yaml_io_test.py
@@ -983,6 +983,25 @@ class YamlDicomSearchTest(unittest.TestCase):
'''))
+class YamlDeltaTest(unittest.TestCase):
+ def test_read_from_delta(self):
+ from apache_beam.yaml.yaml_io import read_from_delta
+ transform = read_from_delta(
+ table="my_table", version=5, timestamp="2026-01-01T00:00:00Z")
+ self.assertIsInstance(transform, beam.managed.Read)
+ self.assertEqual(transform._source, "delta")
+
+ def test_read_from_delta_cdc(self):
+ from apache_beam.yaml.yaml_io import read_from_delta_cdc
+ transform = read_from_delta_cdc(
+ table="my_table",
+ start_version=0,
+ end_version=10,
+ include_metadata_columns=["_change_type"])
+ self.assertIsInstance(transform, beam.managed.Read)
+ self.assertEqual(transform._source, "delta_cdc")
+
+
if __name__ == '__main__':
logging.getLogger().setLevel(logging.INFO)
unittest.main()
diff --git a/sdks/python/setup.py b/sdks/python/setup.py
index 36a3c8a9e61..5ea8257a4b4 100644
--- a/sdks/python/setup.py
+++ b/sdks/python/setup.py
@@ -513,7 +513,8 @@ if __name__ == '__main__':
'sqlalchemy-pytds>=1.0.2',
'pg8000>=1.31.5',
"PyMySQL>=1.1.0",
- 'oracledb>=3.1.1'
+ 'oracledb>=3.1.1',
+ 'deltalake>=0.15.0',
],
'gcp': [
'cachetools>=3.1.0,<7',