diff --git a/examples/java/iceberg/build.gradle b/examples/java/iceberg/build.gradle index 2cff15c5fb0d..7186ff9c7030 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 000000000000..5d6827b0e040 --- /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. + * + *

Overview

+ * + *

This pipeline performs the following steps: + * + *

    + *
  1. Validates Delta Lake Change Data Feed (CDF): 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. + *
  2. Reads Delta Lake CDC Data: 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. + *
  3. Writes CDC Data to GCP Lakehouse: 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. + *
+ * + *

Prerequisites

+ * + * + * + *

Running the Example on Dataflow Portable Runner (Example Default)

+ * + *

This example configures the Dataflow Portable Runner ({@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}. + * + *

1. Reading by Commit Version Range

+ * + *
{@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"
+ * }
+ * + *

2. Reading by Timestamp Range

+ * + *
{@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"
+ * }
+ * + *

Running the Example on Dataflow Streaming Java Runner

+ * + *

When running on the Dataflow Streaming Java Runner (which is Dataflow's default runner + * when {@code --experiments=use_runner_v2} is not enabled), {@code change_type_column} (and {@code + * change_type_map}) can be skipped 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. + * + *

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: + * + *

{@code
+ * // Delta Lake CDC read config on Dataflow Streaming Java Runner (no _change_type column needed):
+ * Map 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 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"));
+ * }
+ * + *

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}: + * + *

{@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"
+ * }
+ */ +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 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 '...' " + + "(e.g. 'my-gcp-project.my-lakehouse-warehouse-bucket.my_namespace.my_iceberg_table'), " + + "a 3-part identifier '..
', or a 2-part Iceberg " + + "table identifier '.
' (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 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 buildDeltaCdcReadConfig(Options options, String project) { + Map 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 buildLakehouseCdcWriteConfig( + Options options, LakehouseTableSpec tableSpec) { + Map catalogProps = + ImmutableMap.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 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 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 readConfig = buildDeltaCdcReadConfig(options, tableSpec.project); + Map 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 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 000000000000..99b3498c9fb3 --- /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 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 '...
', " + + "'..
', or '.
'.", + 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}. + * + *

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 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 000000000000..a6cd955eb675 --- /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 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 hadoopConfig = (Map) readConfig.get("hadoop_config"); + assertEquals("my-gcp-project", hadoopConfig.get("fs.gs.project.id")); + + Map 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 catalogProps = (Map) 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 readConfig = + DeltaLakeToLakehouseCdcExample.buildDeltaCdcReadConfig(options, spec.project); + assertEquals( + Collections.singletonList("_commit_version"), readConfig.get("include_metadata_columns")); + + Map 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 000000000000..02b3042e7c14 --- /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 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 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)); + } +}