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 0c47ade8d29 Adds an end-to-end example for migrating change data 
(CDC)... (#40387)
0c47ade8d29 is described below

commit 0c47ade8d29d63a17b2343e2f2fb189c26490f9d
Author: Chamikara Jayalath <[email protected]>
AuthorDate: Mon Oct 12 14:17:23 2026 -0700

    Adds an end-to-end example for migrating change data (CDC)... (#40387)
    
    * Adds an end-to-end example for migrating change data (CDC) from Delta 
Lake to GCP Lakehouse
    
    * updates
    
    * updates
    
    * Address reviewer comments
---
 examples/java/iceberg/build.gradle                 |  31 ++
 .../iceberg/DeltaLakeToLakehouseCdcExample.java    | 448 +++++++++++++++++++++
 .../iceberg/DeltaLakeToLakehouseCdcUtils.java      | 197 +++++++++
 .../DeltaLakeToLakehouseCdcExampleTest.java        |  96 +++++
 .../iceberg/DeltaLakeToLakehouseCdcUtilsTest.java  | 229 +++++++++++
 5 files changed, 1001 insertions(+)

diff --git a/examples/java/iceberg/build.gradle 
b/examples/java/iceberg/build.gradle
index 2cff15c5fb0..7186ff9c703 100644
--- a/examples/java/iceberg/build.gradle
+++ b/examples/java/iceberg/build.gradle
@@ -39,17 +39,33 @@ dependencies {
   implementation 
enforcedPlatform(library.java.google_cloud_platform_libraries_bom)
   runtimeOnly project(":sdks:java:io:iceberg")
   runtimeOnly project(":sdks:java:io:iceberg:bqms")
+  runtimeOnly(project(":sdks:java:io:delta")) {
+    exclude group: "org.apache.hadoop", module: "hadoop-aws"
+  }
   implementation project(path: ":sdks:java:core", configuration: "shadow")
   implementation project(":sdks:java:extensions:google-cloud-platform-core")
   implementation project(":sdks:java:io:google-cloud-platform")
   implementation project(":sdks:java:managed")
+  implementation library.java.delta_kernel_api
+  implementation library.java.delta_kernel_defaults
   implementation library.java.google_auth_library_oauth2_http
+  implementation library.java.hadoop_common
   implementation library.java.joda_time
+  implementation library.java.slf4j_api
   runtimeOnly project(path: ":runners:direct-java", configuration: "shadow")
   implementation library.java.vendored_guava_32_1_2_jre
   runtimeOnly library.java.hadoop_client
   runtimeOnly library.java.bigdataoss_gcs_connector
 
+  testImplementation library.java.hamcrest
+  testImplementation library.java.junit
+  // Provides DeltaWriteTestUtils, which writes Delta Lake tables for tests.
+  testImplementation(project(path: ":sdks:java:io:delta", configuration: 
"testRuntimeMigration")) {
+    exclude group: "org.apache.hadoop", module: "hadoop-aws"
+    exclude group: "org.testcontainers"
+    exclude group: "org.slf4j", module: "slf4j-simple"
+  }
+
   // Add dependencies for the PreCommit configurations
   // For each runner a project level dependency on the examples project.
   for (String runner : preCommitRunners) {
@@ -65,4 +81,19 @@ dependencies {
 configurations.all {
   // iceberg-core needs avro:1.12.0
   resolutionStrategy.force 'org.apache.avro:avro:1.12.0'
+  // The Delta Lake source reads tables on GCS through the GCS connector, 
whose version enforced by
+  // the GCP libraries BOM conflicts with the latest Hadoop (see 
sdks/java/io/delta/build.gradle). So
+  // use Hadoop 3.3 together with a compatible Parquet version.
+  resolutionStrategy.force 'org.apache.hadoop:hadoop-client:3.3.6'
+  resolutionStrategy.force 'org.apache.hadoop:hadoop-client-api:3.3.6'
+  resolutionStrategy.force 'org.apache.hadoop:hadoop-client-runtime:3.3.6'
+  resolutionStrategy.force 'org.apache.hadoop:hadoop-common:3.3.6'
+  resolutionStrategy.force 'org.apache.hadoop:hadoop-hdfs:3.3.6'
+  resolutionStrategy.force 'org.apache.hadoop:hadoop-hdfs-client:3.3.6'
+  resolutionStrategy.force 'org.apache.parquet:parquet-avro:1.16.0'
+  resolutionStrategy.force 'org.apache.parquet:parquet-hadoop:1.16.0'
+  // Exclude conflicting SLF4J bindings pulled in by the Delta Lake and Hadoop 
dependencies.
+  exclude group: "org.apache.logging.log4j", module: "log4j-slf4j2-impl"
+  exclude group: "org.apache.logging.log4j", module: "log4j-slf4j-impl"
+  exclude group: "org.slf4j", module: "slf4j-reload4j"
 }
diff --git 
a/examples/java/iceberg/src/main/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcExample.java
 
b/examples/java/iceberg/src/main/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcExample.java
new file mode 100644
index 00000000000..5d6827b0e04
--- /dev/null
+++ 
b/examples/java/iceberg/src/main/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcExample.java
@@ -0,0 +1,448 @@
+/*
+ * 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.examples.iceberg;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import 
org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcUtils.LakehouseTableSpec;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.PipelineResult;
+import org.apache.beam.sdk.extensions.gcp.options.GcpOptions;
+import org.apache.beam.sdk.managed.Managed;
+import org.apache.beam.sdk.options.Default;
+import org.apache.beam.sdk.options.Description;
+import org.apache.beam.sdk.options.ExperimentalOptions;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.options.Validation;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.Row;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Splitter;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * An Apache Beam Java example pipeline that reads Change Data Feed (CDC) 
records from a Delta Lake
+ * table on Google Cloud Storage (GCS) and applies those row-level changes 
(`INSERT`,
+ * `UPDATE_BEFORE`, `UPDATE_AFTER`, `DELETE`) to a Google Cloud Platform (GCP) 
Lakehouse table (via
+ * the Lakehouse runtime catalog's Iceberg REST API) using Beam's {@link 
Managed} I/O connectors.
+ *
+ * <h2>Overview</h2>
+ *
+ * <p>This pipeline performs the following steps:
+ *
+ * <ol>
+ *   <li><b>Validates Delta Lake Change Data Feed (CDF):</b> Uses the Delta 
Kernel API to read the
+ *       latest snapshot of the input Delta Lake table and verify that the 
table property {@code
+ *       delta.enableChangeDataFeed = true} is enabled, failing fast with an 
error if it is not.
+ *   <li><b>Reads Delta Lake CDC Data:</b> Uses {@code 
Managed.read(Managed.DELTA_LAKE_CDC)} to read
+ *       change events over either a commit version range ({@code 
--startVersion} / {@code
+ *       --endVersion}) or an ISO-8601 timestamp range ({@code 
--startTimestamp} / {@code
+ *       --endTimestamp}). Standard GCS Hadoop filesystem properties and Delta 
CDC metadata columns
+ *       are configured by default.
+ *   <li><b>Writes CDC Data to GCP Lakehouse:</b> Uses {@code 
Managed.write(Managed.ICEBERG)} in
+ *       {@code merge-on-read} mode against the Lakehouse Iceberg REST catalog 
to apply inserts,
+ *       updates, and deletes ordered by {@code _commit_version}. Standard 
Lakehouse REST catalog
+ *       and Iceberg CDC sink properties are configured by default.
+ * </ol>
+ *
+ * <h2>Prerequisites</h2>
+ *
+ * <ul>
+ *   <li><b>Java 17+:</b> Both the Delta Lake Kernel API and Iceberg require 
Java 17 or later. If
+ *       using SDKMAN, switch to Java 17 before running:
+ *       <pre>{@code
+ * sdk use java 17.0.15-tem
+ * }</pre>
+ *   <li><b>Source Delta Lake Table on GCS:</b> Must have Change Data Feed 
enabled:
+ *       <pre>{@code
+ * ALTER TABLE delta.`gs://my-bucket/path/to/delta_table`
+ * SET TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true');
+ * }</pre>
+ *   <li><b>Target GCP Lakehouse (Iceberg V2) Table:</b> Must be an Iceberg 
format-version 2 table
+ *       registered in the Lakehouse runtime catalog. Its schema must match 
the source Delta Lake
+ *       table's columns. If the Iceberg table does not declare identifier 
(primary-key) fields in
+ *       its table schema, pass {@code --equalityColumns=<col1,col2>} to 
specify the primary-key
+ *       columns used for equality deletes.
+ *   <li><b>GCP Authentication:</b> Set {@code GOOGLE_APPLICATION_CREDENTIALS} 
to a service account
+ *       key JSON file (or configure Application Default Credentials) with 
permissions to access the
+ *       GCS buckets, Lakehouse runtime catalog ({@code roles/biglake.admin}), 
and Dataflow.
+ * </ul>
+ *
+ * <h2>Running the Example on Dataflow Portable Runner (Example Default)</h2>
+ *
+ * <p>This example configures the <b>Dataflow Portable Runner</b> ({@code
+ * --experiments=use_runner_v2}) by default ({@code 
--usePortableRunner=true}). Because the Dataflow
+ * Portable Runner does not yet propagate an element's native Beam {@code 
ValueKind} metadata across
+ * the FnAPI boundary, the source includes {@code _change_type} in {@code 
include_metadata_columns}
+ * and the Iceberg CDC sink is configured with {@code change_type_column: 
"_change_type"} and {@code
+ * change_type_map}.
+ *
+ * <h3>1. Reading by Commit Version Range</h3>
+ *
+ * <pre>{@code
+ * export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
+ *
+ * ./gradlew :examples:java:iceberg:execute \
+ *   
-PmainClass=org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcExample \
+ *   -Pexec.args="--runner=DataflowRunner \
+ *     --project=my-gcp-project \
+ *     --region=us-central1 \
+ *     --tempLocation=gs://my-temp-bucket/temp \
+ *     --deltaTable=gs://my-delta-lake-bucket/delta_lake/employee_data/ \
+ *     
--lakehouseTable=my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table
 \
+ *     --equalityColumns=employee_id \
+ *     --startVersion=2 \
+ *     --endVersion=4"
+ * }</pre>
+ *
+ * <h3>2. Reading by Timestamp Range</h3>
+ *
+ * <pre>{@code
+ * export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
+ *
+ * ./gradlew :examples:java:iceberg:execute \
+ *   
-PmainClass=org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcExample \
+ *   -Pexec.args="--runner=DataflowRunner \
+ *     --project=my-gcp-project \
+ *     --region=us-central1 \
+ *     --tempLocation=gs://my-temp-bucket/temp \
+ *     --deltaTable=gs://my-delta-lake-bucket/delta_lake/employee_data/ \
+ *     
--lakehouseTable=my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table
 \
+ *     --equalityColumns=employee_id \
+ *     --startTimestamp=2026-10-01T00:00:00Z \
+ *     --endTimestamp=2026-10-02T00:00:00Z"
+ * }</pre>
+ *
+ * <h2>Running the Example on Dataflow Streaming Java Runner</h2>
+ *
+ * <p>When running on the <b>Dataflow Streaming Java Runner</b> (which is 
Dataflow's default runner
+ * when {@code --experiments=use_runner_v2} is not enabled), {@code 
change_type_column} (and {@code
+ * change_type_map}) <b>can be skipped</b> on the Iceberg CDC sink, and {@code 
_change_type} does
+ * not need to be included in {@code include_metadata_columns} on the Delta 
Lake CDC source. The
+ * Delta Lake CDC reader ({@code Managed.DELTA_LAKE_CDC}) automatically sets 
each emitted {@link
+ * Row}'s native Beam {@code ValueKind} ({@code INSERT}, {@code DELETE}, 
{@code UPDATE_BEFORE},
+ * {@code UPDATE_AFTER}), which the Dataflow Streaming Java Runner preserves 
and passes directly to
+ * the Iceberg CDC sink.
+ *
+ * <p>In your own pipelines running on the Dataflow Streaming Java Runner, you 
do not need to set
+ * any runner-version flag (since it is Dataflow's default when {@code 
use_runner_v2} is not added),
+ * and the {@code Managed} configurations only need {@code _commit_version} 
for ordering:
+ *
+ * <pre>{@code
+ * // Delta Lake CDC read config on Dataflow Streaming Java Runner (no 
_change_type column needed):
+ * Map<String, Object> readConfig = ImmutableMap.of(
+ *     "table", deltaTable,
+ *     "start_version", 2L,
+ *     "end_version", 4L,
+ *     "include_metadata_columns", ImmutableList.of("_commit_version"),
+ *     "hadoop_config", hadoopConfig);
+ *
+ * // Iceberg CDC write config on Dataflow Streaming Java Runner 
(change_type_column can be skipped):
+ * Map<String, Object> writeConfig = ImmutableMap.of(
+ *     "table", tableId,
+ *     "catalog_name", "lakehouse",
+ *     "catalog_properties", catalogProps,
+ *     "mode", "merge-on-read",
+ *     "sequence_number_column", "_commit_version",
+ *     "equality_columns", ImmutableList.of("employee_id"));
+ * }</pre>
+ *
+ * <p>Because this example enables the Dataflow Portable Runner by default 
({@code
+ * --usePortableRunner=true}), you can override it to run on the Dataflow 
Streaming Java Runner (and
+ * skip {@code change_type_column}) by passing {@code 
--usePortableRunner=false}:
+ *
+ * <pre>{@code
+ * export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
+ *
+ * ./gradlew :examples:java:iceberg:execute \
+ *   
-PmainClass=org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcExample \
+ *   -Pexec.args="--runner=DataflowRunner \
+ *     --usePortableRunner=false \
+ *     --project=my-gcp-project \
+ *     --region=us-central1 \
+ *     --tempLocation=gs://my-temp-bucket/temp \
+ *     --deltaTable=gs://my-delta-lake-bucket/delta_lake/employee_data/ \
+ *     
--lakehouseTable=my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table
 \
+ *     --equalityColumns=employee_id \
+ *     --startVersion=2 \
+ *     --endVersion=4"
+ * }</pre>
+ */
+public class DeltaLakeToLakehouseCdcExample {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(DeltaLakeToLakehouseCdcExample.class);
+
+  /** Delta Lake CDC metadata column names produced by {@code 
Managed.DELTA_LAKE_CDC}. */
+  public static final String CHANGE_TYPE_COLUMN = "_change_type";
+
+  public static final String COMMIT_VERSION_COLUMN = "_commit_version";
+
+  /** Default Lakehouse Iceberg REST Catalog endpoint URI. */
+  public static final String DEFAULT_LAKEHOUSE_CATALOG_URI =
+      "https://biglake.googleapis.com/iceberg/v1/restcatalog";;
+
+  /**
+   * Mapping from Delta Lake Change Data Feed {@code _change_type} values to 
the canonical change
+   * types expected by the Iceberg CDC sink.
+   */
+  public static final Map<String, String> DELTA_TO_ICEBERG_CHANGE_TYPES =
+      ImmutableMap.of(
+          "insert", "INSERT",
+          "delete", "DELETE",
+          "update_preimage", "UPDATE_BEFORE",
+          "update_postimage", "UPDATE_AFTER");
+
+  /** Pipeline options for {@link DeltaLakeToLakehouseCdcExample}. */
+  public interface Options extends GcpOptions {
+
+    @Description(
+        "GCS path of the source Delta Lake table repository to read CDC data 
from "
+            + "(e.g. gs://my-bucket/delta_lake/my_table/).")
+    @Validation.Required
+    String getDeltaTable();
+
+    void setDeltaTable(String value);
+
+    @Description(
+        "Target GCP Lakehouse Iceberg table identifier. Accepts either a 
4-part Lakehouse table "
+            + "identifier '<project>.<warehouse_bucket>.<namespace>.<table>' "
+            + "(e.g. 
'my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table'), "
+            + "a 3-part identifier '<warehouse_bucket>.<namespace>.<table>', 
or a 2-part Iceberg "
+            + "table identifier '<namespace>.<table>' (when --warehouse is 
also specified).")
+    @Validation.Required
+    String getLakehouseTable();
+
+    void setLakehouseTable(String value);
+
+    @Description(
+        "Starting Delta Lake commit version (inclusive) to read changes from. "
+            + "Either --startVersion or --startTimestamp must be provided.")
+    @Nullable Long getStartVersion();
+
+    void setStartVersion(@Nullable Long value);
+
+    @Description(
+        "Ending Delta Lake commit version (inclusive) to read changes up to. 
Optional; defaults "
+            + "to the latest commit version if omitted.")
+    @Nullable Long getEndVersion();
+
+    void setEndVersion(@Nullable Long value);
+
+    @Description(
+        "Starting timestamp in ISO-8601 format (e.g. '2026-10-01T00:00:00Z') 
to read Delta Lake "
+            + "changes from. Either --startVersion or --startTimestamp must be 
provided.")
+    @Nullable String getStartTimestamp();
+
+    void setStartTimestamp(@Nullable String value);
+
+    @Description(
+        "Ending timestamp in ISO-8601 format (e.g. '2026-10-01T23:59:59Z') to 
read Delta Lake "
+            + "changes up to. Optional.")
+    @Nullable String getEndTimestamp();
+
+    void setEndTimestamp(@Nullable String value);
+
+    @Description(
+        "GCS warehouse location for the GCP Lakehouse catalog (e.g. 
'gs://my-warehouse-bucket'). "
+            + "Optional when --lakehouseTable is specified as a 3-part or 
4-part identifier containing "
+            + "the warehouse bucket name.")
+    @Nullable String getWarehouse();
+
+    void setWarehouse(@Nullable String value);
+
+    @Description(
+        "Comma-separated list of primary-key (equality-delete) column names 
that uniquely identify "
+            + "rows in the target Lakehouse Iceberg table (e.g. 
'employee_id'). Required if the "
+            + "target Iceberg table does not declare identifier-field-ids in 
its schema or if the "
+            + "table does not exist yet.")
+    @Nullable String getEqualityColumns();
+
+    void setEqualityColumns(@Nullable String value);
+
+    @Description(
+        "If true, applies changes in upsert mode (UPDATE_BEFORE records are 
dropped and "
+            + "INSERT/UPDATE_AFTER records are applied as upserts). Defaults 
to false.")
+    @Default.Boolean(false)
+    boolean getUpsert();
+
+    void setUpsert(boolean value);
+
+    @Description(
+        "If true (default for this example), runs on the Dataflow Portable 
Runner ('use_runner_v2') "
+            + "and configures 'change_type_column' ('_change_type') on the 
Iceberg CDC sink. "
+            + "Set to false to override and run on the Dataflow Streaming Java 
Runner, which skips "
+            + "'change_type_column' and relies on the native Beam ValueKind 
metadata attached to "
+            + "each Row by the Delta Lake CDC reader.")
+    @Default.Boolean(true)
+    boolean getUsePortableRunner();
+
+    void setUsePortableRunner(boolean value);
+
+    @Description("Name of the Iceberg catalog instance. Defaults to 
'lakehouse'.")
+    @Default.String("lakehouse")
+    String getCatalogName();
+
+    void setCatalogName(String value);
+
+    @Description(
+        "Lakehouse Iceberg REST Catalog endpoint URI. Defaults to "
+            + DEFAULT_LAKEHOUSE_CATALOG_URI
+            + ".")
+    @Default.String(DEFAULT_LAKEHOUSE_CATALOG_URI)
+    String getCatalogUri();
+
+    void setCatalogUri(String value);
+  }
+
+  /**
+   * Builds the Hadoop configuration used to access the Delta Lake table on 
GCS, both by the Delta
+   * Lake CDC source and when verifying that Change Data Feed is enabled on 
the table.
+   */
+  private static Map<String, String> buildGcsHadoopConfig(String project) {
+    return ImmutableMap.of(
+        "fs.gs.impl", "com.google.cloud.hadoop.fs.gcs.GoogleHadoopFileSystem",
+        "fs.AbstractFileSystem.gs.impl", 
"com.google.cloud.hadoop.fs.gcs.GoogleHadoopFS",
+        "fs.gs.auth.type", "APPLICATION_DEFAULT",
+        "fs.gs.project.id", project);
+  }
+
+  /** Builds the {@code Managed.read(Managed.DELTA_LAKE_CDC)} configuration 
map. */
+  @VisibleForTesting
+  static Map<String, Object> buildDeltaCdcReadConfig(Options options, String 
project) {
+    Map<String, Object> readConfig = new HashMap<>();
+    readConfig.put("table", options.getDeltaTable());
+    @Nullable Long startVersion = options.getStartVersion();
+    if (startVersion != null) {
+      readConfig.put("start_version", startVersion);
+    }
+    @Nullable Long endVersion = options.getEndVersion();
+    if (endVersion != null) {
+      readConfig.put("end_version", endVersion);
+    }
+    @Nullable String startTimestamp = options.getStartTimestamp();
+    if (startTimestamp != null && !startTimestamp.trim().isEmpty()) {
+      readConfig.put("start_timestamp", startTimestamp.trim());
+    }
+    @Nullable String endTimestamp = options.getEndTimestamp();
+    if (endTimestamp != null && !endTimestamp.trim().isEmpty()) {
+      readConfig.put("end_timestamp", endTimestamp.trim());
+    }
+    readConfig.put(
+        "include_metadata_columns",
+        options.getUsePortableRunner()
+            ? ImmutableList.of(CHANGE_TYPE_COLUMN, COMMIT_VERSION_COLUMN)
+            : ImmutableList.of(COMMIT_VERSION_COLUMN));
+    readConfig.put("hadoop_config", buildGcsHadoopConfig(project));
+    return readConfig;
+  }
+
+  /** Builds the {@code Managed.write(Managed.ICEBERG)} CDC configuration map 
for GCP Lakehouse. */
+  @VisibleForTesting
+  static Map<String, Object> buildLakehouseCdcWriteConfig(
+      Options options, LakehouseTableSpec tableSpec) {
+    Map<String, String> catalogProps =
+        ImmutableMap.<String, String>builder()
+            .put("type", "rest")
+            .put("uri", options.getCatalogUri())
+            .put("warehouse", tableSpec.warehouse)
+            .put("header.x-goog-user-project", tableSpec.project)
+            .put("io-impl", "org.apache.iceberg.gcp.gcs.GCSFileIO")
+            .put("rest.auth.type", 
"org.apache.iceberg.gcp.auth.GoogleAuthManager")
+            .build();
+
+    Map<String, Object> writeConfig = new HashMap<>();
+    writeConfig.put("table", tableSpec.tableId);
+    writeConfig.put("catalog_name", options.getCatalogName());
+    writeConfig.put("catalog_properties", catalogProps);
+    writeConfig.put("mode", "merge-on-read");
+    writeConfig.put("sequence_number_column", COMMIT_VERSION_COLUMN);
+    if (options.getUsePortableRunner()) {
+      writeConfig.put("change_type_column", CHANGE_TYPE_COLUMN);
+      writeConfig.put("change_type_map", DELTA_TO_ICEBERG_CHANGE_TYPES);
+    }
+    writeConfig.put("upsert", options.getUpsert());
+
+    @Nullable String equalityColsStr = options.getEqualityColumns();
+    if (equalityColsStr != null && !equalityColsStr.trim().isEmpty()) {
+      List<String> equalityCols =
+          
Splitter.on(',').trimResults().omitEmptyStrings().splitToList(equalityColsStr).stream()
+              .collect(Collectors.toList());
+      if (!equalityCols.isEmpty()) {
+        writeConfig.put("equality_columns", equalityCols);
+      }
+    }
+    return writeConfig;
+  }
+
+  public static void runDeltaLakeToLakehouseCdc(Options options) {
+    DeltaLakeToLakehouseCdcUtils.validateRangeOptions(options);
+    LakehouseTableSpec tableSpec = 
DeltaLakeToLakehouseCdcUtils.resolveLakehouseTableSpec(options);
+    if (options.getProject() == null || options.getProject().trim().isEmpty()) 
{
+      options.setProject(tableSpec.project);
+    }
+
+    // Configure Dataflow Portable Runner by default unless 
--usePortableRunner=false (Dataflow
+    // Streaming Java Runner) is specified.
+    if (options.getUsePortableRunner()) {
+      ExperimentalOptions.addExperiment(options.as(ExperimentalOptions.class), 
"use_runner_v2");
+    }
+
+    // Verify that Change Data Feed is enabled on the source Delta Lake table.
+    DeltaLakeToLakehouseCdcUtils.verifyChangeDataFeedEnabled(
+        options.getDeltaTable(), buildGcsHadoopConfig(tableSpec.project));
+
+    Map<String, Object> readConfig = buildDeltaCdcReadConfig(options, 
tableSpec.project);
+    Map<String, Object> writeConfig = buildLakehouseCdcWriteConfig(options, 
tableSpec);
+
+    LOG.info(
+        "Starting Delta Lake to GCP Lakehouse CDC pipeline: source='{}', 
target='{}' (warehouse='{}', project='{}')",
+        options.getDeltaTable(),
+        tableSpec.tableId,
+        tableSpec.warehouse,
+        tableSpec.project);
+
+    Pipeline p = Pipeline.create(options);
+
+    PCollection<Row> changes =
+        p.apply(
+                "ReadDeltaLakeChangeFeed",
+                Managed.read(Managed.DELTA_LAKE_CDC).withConfig(readConfig))
+            .getSinglePCollection();
+
+    changes.apply(
+        "ApplyChangesToLakehouse", 
Managed.write(Managed.ICEBERG).withConfig(writeConfig));
+
+    PipelineResult.State state = p.run().waitUntilFinish();
+    if (state != PipelineResult.State.DONE) {
+      throw new IllegalStateException(
+          String.format(
+              "Delta Lake to Lakehouse CDC pipeline failed with terminal 
state: %s", state));
+    }
+  }
+
+  public static void main(String[] args) {
+    Options options = 
PipelineOptionsFactory.fromArgs(args).withValidation().as(Options.class);
+    runDeltaLakeToLakehouseCdc(options);
+  }
+}
diff --git 
a/examples/java/iceberg/src/main/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcUtils.java
 
b/examples/java/iceberg/src/main/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcUtils.java
new file mode 100644
index 00000000000..99b3498c9fb
--- /dev/null
+++ 
b/examples/java/iceberg/src/main/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcUtils.java
@@ -0,0 +1,197 @@
+/*
+ * 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.examples.iceberg;
+
+import io.delta.kernel.Snapshot;
+import io.delta.kernel.Table;
+import io.delta.kernel.defaults.engine.DefaultEngine;
+import io.delta.kernel.engine.Engine;
+import io.delta.kernel.exceptions.TableNotFoundException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcExample.Options;
+import org.apache.hadoop.conf.Configuration;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helpers for {@link DeltaLakeToLakehouseCdcExample} that parse the target 
Lakehouse table
+ * identifier and validate the pipeline options and the source Delta Lake 
table.
+ */
+final class DeltaLakeToLakehouseCdcUtils {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(DeltaLakeToLakehouseCdcUtils.class);
+
+  /** Delta Lake table property that enables the Change Data Feed of a table. 
*/
+  static final String ENABLE_CHANGE_DATA_FEED_PROPERTY = 
"delta.enableChangeDataFeed";
+
+  private DeltaLakeToLakehouseCdcUtils() {}
+
+  /** Parsed GCP Lakehouse table coordinates (`project`, `warehouse`, and 
`namespace.table`). */
+  static final class LakehouseTableSpec {
+    final String project;
+    final String warehouse;
+    final String tableId;
+
+    LakehouseTableSpec(String project, String warehouse, String tableId) {
+      this.project = project;
+      this.warehouse = warehouse;
+      this.tableId = tableId;
+    }
+  }
+
+  /**
+   * Resolves the GCP project, GCS warehouse URI, and 2-part Iceberg {@code 
namespace.table}
+   * identifier from {@link Options}.
+   */
+  static LakehouseTableSpec resolveLakehouseTableSpec(Options options) {
+    String rawTable = options.getLakehouseTable();
+    if (rawTable == null || rawTable.trim().isEmpty()) {
+      throw new IllegalArgumentException("--lakehouseTable must not be 
empty.");
+    }
+    List<String> parts = Arrays.asList(rawTable.trim().split("\\."));
+    @Nullable String project = options.getProject();
+    @Nullable String warehouse = options.getWarehouse();
+    String tableId;
+
+    if (parts.size() == 4) {
+      project = parts.get(0);
+      if (warehouse == null || warehouse.trim().isEmpty()) {
+        warehouse = "gs://" + parts.get(1);
+      }
+      tableId = parts.get(2) + "." + parts.get(3);
+    } else if (parts.size() == 3) {
+      if (warehouse == null || warehouse.trim().isEmpty()) {
+        warehouse = "gs://" + parts.get(0);
+      }
+      tableId = parts.get(1) + "." + parts.get(2);
+    } else if (parts.size() == 2) {
+      tableId = rawTable.trim();
+    } else {
+      throw new IllegalArgumentException(
+          String.format(
+              "Invalid --lakehouseTable '%s'. Expected 
'<project>.<warehouse_bucket>.<namespace>.<table>', "
+                  + "'<warehouse_bucket>.<namespace>.<table>', or 
'<namespace>.<table>'.",
+              rawTable));
+    }
+
+    if (project == null || project.trim().isEmpty()) {
+      throw new IllegalArgumentException(
+          "GCP project must be specified via --project or as the first 
component of a 4-part "
+              + "--lakehouseTable identifier.");
+    }
+    if (warehouse == null || warehouse.trim().isEmpty()) {
+      throw new IllegalArgumentException(
+          "Lakehouse warehouse bucket must be specified via --warehouse or as 
part of a 3-part / "
+              + "4-part --lakehouseTable identifier.");
+    }
+    String normalizedWarehouse =
+        warehouse.startsWith("gs://") ? warehouse : "gs://" + warehouse.trim();
+
+    return new LakehouseTableSpec(project.trim(), normalizedWarehouse, 
tableId);
+  }
+
+  /**
+   * Validates that either a commit version range or a timestamp range (and 
not a mix of both) is
+   * configured on {@link Options}.
+   */
+  static void validateRangeOptions(Options options) {
+    @Nullable Long startVersion = options.getStartVersion();
+    @Nullable Long endVersion = options.getEndVersion();
+    @Nullable String startTimestamp = options.getStartTimestamp();
+    @Nullable String endTimestamp = options.getEndTimestamp();
+
+    boolean hasStartVersion = startVersion != null;
+    boolean hasEndVersion = endVersion != null;
+    boolean hasStartTimestamp = startTimestamp != null && 
!startTimestamp.trim().isEmpty();
+    boolean hasEndTimestamp = endTimestamp != null && 
!endTimestamp.trim().isEmpty();
+
+    if (!hasStartVersion && !hasStartTimestamp) {
+      throw new IllegalArgumentException(
+          "Either --startVersion or --startTimestamp must be provided to read 
Delta Lake CDC data.");
+    }
+    if (hasStartVersion && hasStartTimestamp) {
+      throw new IllegalArgumentException(
+          "Cannot set both --startVersion and --startTimestamp; specify either 
a version range or "
+              + "a timestamp range.");
+    }
+    if (hasEndVersion && hasEndTimestamp) {
+      throw new IllegalArgumentException(
+          "Cannot set both --endVersion and --endTimestamp; specify either a 
version range or "
+              + "a timestamp range.");
+    }
+    if (hasStartVersion && hasEndTimestamp) {
+      throw new IllegalArgumentException(
+          "Cannot mix --startVersion with --endTimestamp; use --endVersion 
instead.");
+    }
+    if (hasStartTimestamp && hasEndVersion) {
+      throw new IllegalArgumentException(
+          "Cannot mix --startTimestamp with --endVersion; use --endTimestamp 
instead.");
+    }
+    if (startVersion != null && endVersion != null && startVersion > 
endVersion) {
+      throw new IllegalArgumentException(
+          String.format(
+              "--startVersion (%d) must be less than or equal to --endVersion 
(%d).",
+              startVersion, endVersion));
+    }
+  }
+
+  /**
+   * Verifies that Change Data Feed is enabled on the input Delta Lake table, 
i.e. that the latest
+   * snapshot of the table has the table property {@code 
delta.enableChangeDataFeed = true}.
+   *
+   * <p>The table is read using the Delta Kernel API with the given Hadoop 
configuration (for
+   * example, the GCS connector settings that the Delta Lake CDC source uses 
to access the table).
+   *
+   * @throws IllegalArgumentException if there is no Delta Lake table at 
{@code deltaTablePath}
+   * @throws IllegalStateException if {@code delta.enableChangeDataFeed = 
true} is not set
+   */
+  static void verifyChangeDataFeedEnabled(String deltaTablePath, Map<String, 
String> hadoopConfig) {
+    Configuration conf = new Configuration();
+    hadoopConfig.forEach(conf::set);
+    Engine engine = DefaultEngine.create(conf);
+
+    Snapshot snapshot;
+    try {
+      snapshot = Table.forPath(engine, 
deltaTablePath).getLatestSnapshot(engine);
+    } catch (TableNotFoundException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "No Delta Lake table found at '%s'. Verify that it is a valid 
Delta Lake table.",
+              deltaTablePath),
+          e);
+    }
+
+    @Nullable String changeDataFeed =
+        snapshot.getTableProperties().get(ENABLE_CHANGE_DATA_FEED_PROPERTY);
+    if (!"true".equalsIgnoreCase(changeDataFeed)) {
+      throw new IllegalStateException(
+          String.format(
+              "Delta Lake table '%s' does not have 'delta.enableChangeDataFeed 
= true' enabled "
+                  + "(found '%s' at version %d). Enable Change Data Feed on 
the table before "
+                  + "running this pipeline.",
+              deltaTablePath, changeDataFeed, snapshot.getVersion()));
+    }
+    LOG.info(
+        "Verified 'delta.enableChangeDataFeed = true' on Delta Lake table '{}' 
(version {}).",
+        deltaTablePath,
+        snapshot.getVersion());
+  }
+}
diff --git 
a/examples/java/iceberg/src/test/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcExampleTest.java
 
b/examples/java/iceberg/src/test/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcExampleTest.java
new file mode 100644
index 00000000000..a6cd955eb67
--- /dev/null
+++ 
b/examples/java/iceberg/src/test/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcExampleTest.java
@@ -0,0 +1,96 @@
+/*
+ * 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.examples.iceberg;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcExample.Options;
+import 
org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcUtils.LakehouseTableSpec;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link DeltaLakeToLakehouseCdcExample}. */
+@RunWith(JUnit4.class)
+public class DeltaLakeToLakehouseCdcExampleTest {
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testBuildManagedConfigs() {
+    Options options = PipelineOptionsFactory.as(Options.class);
+    
options.setDeltaTable("gs://my-delta-lake-bucket/delta_lake/employee_data/");
+    options.setLakehouseTable(
+        
"my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table");
+    options.setEqualityColumns("employee_id");
+    options.setStartVersion(2L);
+    options.setEndVersion(4L);
+
+    LakehouseTableSpec spec = 
DeltaLakeToLakehouseCdcUtils.resolveLakehouseTableSpec(options);
+    Map<String, Object> readConfig =
+        DeltaLakeToLakehouseCdcExample.buildDeltaCdcReadConfig(options, 
spec.project);
+    assertEquals("gs://my-delta-lake-bucket/delta_lake/employee_data/", 
readConfig.get("table"));
+    assertEquals(2L, readConfig.get("start_version"));
+    assertEquals(4L, readConfig.get("end_version"));
+    assertEquals(
+        Arrays.asList("_change_type", "_commit_version"),
+        readConfig.get("include_metadata_columns"));
+    Map<String, String> hadoopConfig = (Map<String, String>) 
readConfig.get("hadoop_config");
+    assertEquals("my-gcp-project", hadoopConfig.get("fs.gs.project.id"));
+
+    Map<String, Object> writeConfig =
+        DeltaLakeToLakehouseCdcExample.buildLakehouseCdcWriteConfig(options, 
spec);
+    assertEquals("my_namespace.my_iceberg_table", writeConfig.get("table"));
+    assertEquals("merge-on-read", writeConfig.get("mode"));
+    assertEquals("_commit_version", writeConfig.get("sequence_number_column"));
+    assertEquals("_change_type", writeConfig.get("change_type_column"));
+    assertEquals(Arrays.asList("employee_id"), 
writeConfig.get("equality_columns"));
+    Map<String, String> catalogProps = (Map<String, String>) 
writeConfig.get("catalog_properties");
+    assertEquals("rest", catalogProps.get("type"));
+    assertEquals("gs://my-lakehouse-warehouse-bucket", 
catalogProps.get("warehouse"));
+    assertEquals("my-gcp-project", 
catalogProps.get("header.x-goog-user-project"));
+  }
+
+  @Test
+  public void 
testBuildManagedConfigsStreamingJavaRunnerSkipsChangeTypeColumn() {
+    Options options = PipelineOptionsFactory.as(Options.class);
+    options.setUsePortableRunner(false);
+    
options.setDeltaTable("gs://my-delta-lake-bucket/delta_lake/employee_data/");
+    options.setLakehouseTable(
+        
"my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table");
+    options.setEqualityColumns("employee_id");
+    options.setStartVersion(2L);
+    options.setEndVersion(4L);
+
+    LakehouseTableSpec spec = 
DeltaLakeToLakehouseCdcUtils.resolveLakehouseTableSpec(options);
+    Map<String, Object> readConfig =
+        DeltaLakeToLakehouseCdcExample.buildDeltaCdcReadConfig(options, 
spec.project);
+    assertEquals(
+        Collections.singletonList("_commit_version"), 
readConfig.get("include_metadata_columns"));
+
+    Map<String, Object> writeConfig =
+        DeltaLakeToLakehouseCdcExample.buildLakehouseCdcWriteConfig(options, 
spec);
+    assertEquals("_commit_version", writeConfig.get("sequence_number_column"));
+    assertFalse(writeConfig.containsKey("change_type_column"));
+    assertFalse(writeConfig.containsKey("change_type_map"));
+  }
+}
diff --git 
a/examples/java/iceberg/src/test/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcUtilsTest.java
 
b/examples/java/iceberg/src/test/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcUtilsTest.java
new file mode 100644
index 00000000000..02b3042e7c1
--- /dev/null
+++ 
b/examples/java/iceberg/src/test/java/org/apache/beam/examples/iceberg/DeltaLakeToLakehouseCdcUtilsTest.java
@@ -0,0 +1,229 @@
+/*
+ * 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.examples.iceberg;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import io.delta.kernel.Operation;
+import io.delta.kernel.Table;
+import io.delta.kernel.defaults.engine.DefaultEngine;
+import io.delta.kernel.engine.Engine;
+import io.delta.kernel.types.LongType;
+import io.delta.kernel.types.StringType;
+import io.delta.kernel.types.StructType;
+import io.delta.kernel.utils.CloseableIterable;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcExample.Options;
+import 
org.apache.beam.examples.iceberg.DeltaLakeToLakehouseCdcUtils.LakehouseTableSpec;
+import org.apache.beam.sdk.io.delta.DeltaWriteTestUtils;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.values.Row;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.hadoop.conf.Configuration;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link DeltaLakeToLakehouseCdcUtils}. */
+@RunWith(JUnit4.class)
+public class DeltaLakeToLakehouseCdcUtilsTest {
+
+  private static final String ENGINE_INFO = "DeltaLakeToLakehouseCdcUtilsTest";
+
+  private static final String ENABLE_CDF_PROPERTY = 
"delta.enableChangeDataFeed";
+
+  private static final StructType DELTA_SCHEMA =
+      new StructType().add("employee_id", LongType.LONG).add("name", 
StringType.STRING);
+
+  private static final Schema BEAM_SCHEMA =
+      
Schema.builder().addInt64Field("employee_id").addStringField("name").build();
+
+  @Rule public final TemporaryFolder tmpFolder = new TemporaryFolder();
+
+  private final Engine engine = DefaultEngine.create(new Configuration());
+
+  private static Row employee(long id, String name) {
+    return Row.withSchema(BEAM_SCHEMA).addValues(id, name).build();
+  }
+
+  /**
+   * Appends rows to a Delta Lake table as commit {@code version} using {@link 
DeltaWriteTestUtils}.
+   * Version 0 creates the table with Change Data Feed enabled.
+   */
+  private void appendRows(String tablePath, long version, Row... rows) throws 
Exception {
+    DeltaWriteTestUtils.writeAppendCommit(
+        engine, tablePath, version, System.currentTimeMillis(), DELTA_SCHEMA, 
Arrays.asList(rows));
+  }
+
+  /** Creates an empty Delta Lake table (commit version 0) with the given 
table properties. */
+  private String createDeltaTable(Map<String, String> tableProperties) throws 
IOException {
+    String tablePath = tmpFolder.newFolder().getAbsolutePath();
+    Table.forPath(engine, tablePath)
+        .createTransactionBuilder(engine, ENGINE_INFO, Operation.CREATE_TABLE)
+        .withSchema(engine, DELTA_SCHEMA)
+        .withTableProperties(engine, tableProperties)
+        .build(engine)
+        .commit(engine, CloseableIterable.emptyIterable());
+    return tablePath;
+  }
+
+  /**
+   * Commits a new table version that updates the given table properties, like 
{@code ALTER TABLE
+   * ... SET TBLPROPERTIES}.
+   */
+  private void setTableProperties(String tablePath, Map<String, String> 
tableProperties) {
+    Table.forPath(engine, tablePath)
+        .createTransactionBuilder(engine, ENGINE_INFO, Operation.MANUAL_UPDATE)
+        .withTableProperties(engine, tableProperties)
+        .build(engine)
+        .commit(engine, CloseableIterable.emptyIterable());
+  }
+
+  /** Runs the Change Data Feed check on a local table, which needs no Hadoop 
configuration. */
+  private static void verifyChangeDataFeedEnabled(String tablePath) {
+    DeltaLakeToLakehouseCdcUtils.verifyChangeDataFeedEnabled(tablePath, 
Collections.emptyMap());
+  }
+
+  @Test
+  public void testVerifyChangeDataFeedEnabledPassesWhenEnabled() throws 
Exception {
+    String tablePath = tmpFolder.newFolder().getAbsolutePath();
+    appendRows(tablePath, 0L, employee(1L, "Alice"), employee(2L, "Bob"));
+    appendRows(tablePath, 1L, employee(3L, "Carol"));
+
+    verifyChangeDataFeedEnabled(tablePath);
+  }
+
+  @Test
+  public void 
testVerifyChangeDataFeedEnabledPassesWhenEnabledInLatestSnapshot() throws 
Exception {
+    // Version 0: the table is created without Change Data Feed.
+    String tablePath = createDeltaTable(Collections.emptyMap());
+    // Version 1: ALTER TABLE ... SET TBLPROPERTIES 
('delta.enableChangeDataFeed' = 'true')
+    setTableProperties(tablePath, ImmutableMap.of(ENABLE_CDF_PROPERTY, 
"true"));
+    // Version 2: a data write, which doesn't change the table metadata.
+    appendRows(tablePath, 2L, employee(1L, "Alice"));
+
+    verifyChangeDataFeedEnabled(tablePath);
+  }
+
+  @Test
+  public void testVerifyChangeDataFeedEnabledFailsWhenFalse() throws Exception 
{
+    String tablePath = createDeltaTable(ImmutableMap.of(ENABLE_CDF_PROPERTY, 
"false"));
+
+    IllegalStateException ex =
+        assertThrows(IllegalStateException.class, () -> 
verifyChangeDataFeedEnabled(tablePath));
+    assertThat(ex.getMessage(), containsString("delta.enableChangeDataFeed = 
true"));
+  }
+
+  @Test
+  public void 
testVerifyChangeDataFeedEnabledFailsWhenMissingFromTableProperties()
+      throws Exception {
+    String tablePath = createDeltaTable(Collections.emptyMap());
+
+    IllegalStateException ex =
+        assertThrows(IllegalStateException.class, () -> 
verifyChangeDataFeedEnabled(tablePath));
+    assertThat(ex.getMessage(), containsString("delta.enableChangeDataFeed = 
true"));
+  }
+
+  @Test
+  public void 
testVerifyChangeDataFeedEnabledFailsWhenDisabledInLatestSnapshot() throws 
Exception {
+    // Version 0: the table is created with Change Data Feed enabled.
+    String tablePath = tmpFolder.newFolder().getAbsolutePath();
+    appendRows(tablePath, 0L, employee(1L, "Alice"));
+    // Version 1: ALTER TABLE ... SET TBLPROPERTIES 
('delta.enableChangeDataFeed' = 'false')
+    setTableProperties(tablePath, ImmutableMap.of(ENABLE_CDF_PROPERTY, 
"false"));
+
+    IllegalStateException ex =
+        assertThrows(IllegalStateException.class, () -> 
verifyChangeDataFeedEnabled(tablePath));
+    assertThat(ex.getMessage(), containsString("delta.enableChangeDataFeed = 
true"));
+  }
+
+  @Test
+  public void testVerifyChangeDataFeedEnabledFailsWhenNotADeltaTable() throws 
Exception {
+    String emptyDir = 
tmpFolder.newFolder("not_a_delta_table").getAbsolutePath();
+
+    IllegalArgumentException ex =
+        assertThrows(IllegalArgumentException.class, () -> 
verifyChangeDataFeedEnabled(emptyDir));
+    assertThat(ex.getMessage(), containsString("No Delta Lake table found"));
+  }
+
+  @Test
+  public void testResolveLakehouseTableSpecFourPart() {
+    Options options = PipelineOptionsFactory.as(Options.class);
+    options.setLakehouseTable(
+        
"my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table");
+
+    LakehouseTableSpec spec = 
DeltaLakeToLakehouseCdcUtils.resolveLakehouseTableSpec(options);
+    assertEquals("my-gcp-project", spec.project);
+    assertEquals("gs://my-lakehouse-warehouse-bucket", spec.warehouse);
+    assertEquals("my_namespace.my_iceberg_table", spec.tableId);
+  }
+
+  @Test
+  public void 
testResolveLakehouseTableSpecTwoPartWithExplicitWarehouseAndProject() {
+    Options options = PipelineOptionsFactory.as(Options.class);
+    options.setProject("my-gcp-project");
+    options.setWarehouse("gs://my-lakehouse-warehouse-bucket");
+    options.setLakehouseTable("my_namespace.my_iceberg_table");
+
+    LakehouseTableSpec spec = 
DeltaLakeToLakehouseCdcUtils.resolveLakehouseTableSpec(options);
+    assertEquals("my-gcp-project", spec.project);
+    assertEquals("gs://my-lakehouse-warehouse-bucket", spec.warehouse);
+    assertEquals("my_namespace.my_iceberg_table", spec.tableId);
+  }
+
+  @Test
+  public void testValidateRangeOptionsVersionAndTimestampRanges() {
+    Options versionOpts = PipelineOptionsFactory.as(Options.class);
+    versionOpts.setStartVersion(1L);
+    versionOpts.setEndVersion(3L);
+    DeltaLakeToLakehouseCdcUtils.validateRangeOptions(versionOpts);
+
+    Options tsOpts = PipelineOptionsFactory.as(Options.class);
+    tsOpts.setStartTimestamp("2026-10-01T00:00:00Z");
+    tsOpts.setEndTimestamp("2026-10-02T00:00:00Z");
+    DeltaLakeToLakehouseCdcUtils.validateRangeOptions(tsOpts);
+
+    Options missingOpts = PipelineOptionsFactory.as(Options.class);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> DeltaLakeToLakehouseCdcUtils.validateRangeOptions(missingOpts));
+
+    Options mixedOpts = PipelineOptionsFactory.as(Options.class);
+    mixedOpts.setStartVersion(1L);
+    mixedOpts.setStartTimestamp("2026-10-01T00:00:00Z");
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> DeltaLakeToLakehouseCdcUtils.validateRangeOptions(mixedOpts));
+
+    Options invertedOpts = PipelineOptionsFactory.as(Options.class);
+    invertedOpts.setStartVersion(5L);
+    invertedOpts.setEndVersion(2L);
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> DeltaLakeToLakehouseCdcUtils.validateRangeOptions(invertedOpts));
+  }
+}

Reply via email to