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. + * + *
This pipeline performs the following steps: + * + *
{@code
+ * sdk use java 17.0.15-tem
+ * }
+ * {@code
+ * ALTER TABLE delta.`gs://my-bucket/path/to/delta_table`
+ * SET TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true');
+ * }
+ * 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}. + * + *
{@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"
+ * }
+ *
+ * {@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"
+ * }
+ *
+ * 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