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 be3d44e841f Updates Delta CDC read to fail when schemas are different
at... (#40868)
be3d44e841f is described below
commit be3d44e841f41faf166bbea385fc5ebae8ba2e20
Author: Chamikara Jayalath <[email protected]>
AuthorDate: Wed Oct 28 16:47:08 2026 -0700
Updates Delta CDC read to fail when schemas are different at... (#40868)
---
.../beam_PostCommit_Java_Delta_IO_Dataflow.json | 2 +-
.../beam_PreCommit_Java_Delta_IO_Direct.json | 2 +-
.../beam/sdk/io/delta/CreateCDCReadTasksDoFn.java | 44 ++++-
.../java/org/apache/beam/sdk/io/delta/DeltaIO.java | 39 +++-
.../apache/beam/sdk/io/delta/DeltaSchemaUtils.java | 188 ++++++++++++++++++
.../org/apache/beam/sdk/io/delta/DeltaIOTest.java | 206 +++++++++++++++++++
.../beam/sdk/io/delta/DeltaSchemaUtilsTest.java | 220 +++++++++++++++++++++
.../beam/sdk/io/delta/DeltaWriteTestUtils.java | 125 ++++++++++++
8 files changed, 814 insertions(+), 12 deletions(-)
diff --git a/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
b/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
index 9a4fbff0c0b..a7c9525dc7e 100644
--- a/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
+++ b/.github/trigger_files/beam_PostCommit_Java_Delta_IO_Dataflow.json
@@ -1,5 +1,5 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run.",
- "modification": 2,
+ "modification": 4,
"https://github.com/apache/beam/pull/39990": "removing dead code from
FnApiDoFnRunner"
}
diff --git a/.github/trigger_files/beam_PreCommit_Java_Delta_IO_Direct.json
b/.github/trigger_files/beam_PreCommit_Java_Delta_IO_Direct.json
index e3d6056a5de..c537844dc84 100644
--- a/.github/trigger_files/beam_PreCommit_Java_Delta_IO_Direct.json
+++ b/.github/trigger_files/beam_PreCommit_Java_Delta_IO_Direct.json
@@ -1,4 +1,4 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run",
- "modification": 1
+ "modification": 3
}
diff --git
a/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/CreateCDCReadTasksDoFn.java
b/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/CreateCDCReadTasksDoFn.java
index 9a0049a8651..a6dfc01c811 100644
---
a/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/CreateCDCReadTasksDoFn.java
+++
b/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/CreateCDCReadTasksDoFn.java
@@ -31,6 +31,7 @@ import
io.delta.kernel.internal.DeltaLogActionUtils.DeltaAction;
import io.delta.kernel.internal.TableImpl;
import io.delta.kernel.internal.actions.AddCDCFile;
import io.delta.kernel.internal.actions.AddFile;
+import io.delta.kernel.internal.actions.Metadata;
import io.delta.kernel.internal.util.VectorUtils;
import io.delta.kernel.utils.CloseableIterator;
import java.time.Instant;
@@ -108,18 +109,28 @@ class CreateCDCReadTasksDoFn extends DoFn<String,
DeltaCDCReadTask> {
resolvedStartVersion, resolvedEndVersion));
}
- // 2. Load snapshot at resolvedEndVersion to get the scanStateRow
- // We use endVersion's schema because it represents the latest schema in
the
- // read range
- // which handles schema evolution (older files will just lack new columns).
- Snapshot endSnapshot = table.getSnapshotAsOfVersion(engine,
resolvedEndVersion);
+ // 2. Load snapshots at resolvedStartVersion and resolvedEndVersion. All
changes within the
+ // range
+ // are read using the schema at resolvedEndVersion, which handles schema
evolution (older files
+ // will just lack new columns). This is only supported if the range only
spans additive schema
+ // changes (see DeltaSchemaUtils), which is verified for the schema at
resolvedStartVersion here
+ // and for all schema changes within the range while reading the commits
of the range below.
+ Snapshot startSnapshot = table.getSnapshotAsOfVersion(engine,
resolvedStartVersion);
+ Snapshot endSnapshot =
+ resolvedStartVersion == resolvedEndVersion
+ ? startSnapshot
+ : table.getSnapshotAsOfVersion(engine, resolvedEndVersion);
+ io.delta.kernel.types.StructType startSchema = startSnapshot.getSchema();
+ io.delta.kernel.types.StructType endSchema = endSnapshot.getSchema();
+ if (startSchema == null || endSchema == null) {
+ throw new IllegalStateException("Table schema is null.");
+ }
+ DeltaSchemaUtils.checkReadCompatible(
+ startSchema, resolvedStartVersion, endSchema, resolvedStartVersion,
resolvedEndVersion);
Scan scan = endSnapshot.getScanBuilder().build();
Row scanState = scan.getScanState(engine);
SerializableRow serializableScanState = new SerializableRow(scanState);
- // 3. Load snapshot at resolvedStartVersion to initialize the CommitRange
- Snapshot startSnapshot = table.getSnapshotAsOfVersion(engine,
resolvedStartVersion);
-
CommitRangeBuilder rangeBuilder =
TableManager.loadCommitRange(
tablePath,
CommitRangeBuilder.CommitBoundary.atVersion(resolvedStartVersion));
@@ -129,9 +140,11 @@ class CreateCDCReadTasksDoFn extends DoFn<String,
DeltaCDCReadTask> {
// We need both CDC and ADD actions.
// If a commit version has CDC files, we only read CDC files.
// If a commit version has no CDC files, we read ADD files (inserts).
+ // We also need METADATA actions to verify all schema changes within the
range.
Set<DeltaAction> actionSet = new HashSet<>();
actionSet.add(DeltaAction.CDC);
actionSet.add(DeltaAction.ADD);
+ actionSet.add(DeltaAction.METADATA);
// 4. Iterate over commits in the range and group actions by version
try (CloseableIterator<ColumnarBatch> batchIter =
@@ -144,11 +157,26 @@ class CreateCDCReadTasksDoFn extends DoFn<String,
DeltaCDCReadTask> {
int timestampIdx = batch.getSchema().indexOf("timestamp");
int cdcIdx = batch.getSchema().indexOf("cdc");
int addIdx = batch.getSchema().indexOf("add");
+ int metadataIdx =
batch.getSchema().indexOf(DeltaAction.METADATA.colName);
for (int i = 0; i < batch.getSize(); i++) {
long version = batch.getColumnVector(versionIdx).getLong(i);
long timestamp = batch.getColumnVector(timestampIdx).getLong(i);
+ // A METADATA action means that the table metadata, which includes
the table schema, was
+ // updated in this version. Data written in this or later versions
(until the next
+ // update) uses the new schema, so it also has to be readable using
the end schema.
+ // Verifying every schema change within the range, and not only the
schemas at the start
+ // and end versions, catches non-additive changes that are reverted
within the range, for
+ // example, a column that was added and then dropped again. Since
tasks are only emitted
+ // after all commits of the range were read, no tasks are emitted if
this check fails.
+ if (metadataIdx >= 0 &&
!batch.getColumnVector(metadataIdx).isNullAt(i)) {
+ io.delta.kernel.types.StructType schemaAtVersion =
+ Metadata.fromColumnVector(batch.getColumnVector(metadataIdx),
i).getSchema();
+ DeltaSchemaUtils.checkReadCompatible(
+ schemaAtVersion, version, endSchema, resolvedStartVersion,
resolvedEndVersion);
+ }
+
CommitActionsInfo info =
commitActionsMap.computeIfAbsent(
version, k -> new CommitActionsInfo(version, timestamp));
diff --git
a/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/DeltaIO.java
b/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/DeltaIO.java
index c63fdcacc1c..b745d13160e 100644
--- a/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/DeltaIO.java
+++ b/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/DeltaIO.java
@@ -346,6 +346,18 @@ public class DeltaIO {
TableImpl tableImpl = (TableImpl) table;
+ long resolvedStartVersion;
+ Long startVersionVal = getStartVersion();
+ String startTimestampVal = getStartTimestamp();
+ if (startVersionVal != null) {
+ resolvedStartVersion = startVersionVal;
+ } else if (startTimestampVal != null) {
+ long startMillis =
java.time.Instant.parse(startTimestampVal).toEpochMilli();
+ resolvedStartVersion = tableImpl.getVersionAtOrAfterTimestamp(engine,
startMillis);
+ } else {
+ throw new IllegalArgumentException("Either startVersion or
startTimestamp must be set.");
+ }
+
long resolvedEndVersion;
Long endVersionVal = getEndVersion();
String endTimestampVal = getEndTimestamp();
@@ -358,11 +370,34 @@ public class DeltaIO {
resolvedEndVersion = table.getLatestSnapshot(engine).getVersion();
}
- Snapshot endSnapshot = table.getSnapshotAsOfVersion(engine,
resolvedEndVersion);
+ if (resolvedStartVersion > resolvedEndVersion) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Resolved start version %d is greater than resolved end
version %d",
+ resolvedStartVersion, resolvedEndVersion));
+ }
+
+ Snapshot startSnapshot = table.getSnapshotAsOfVersion(engine,
resolvedStartVersion);
+ Snapshot endSnapshot =
+ resolvedStartVersion == resolvedEndVersion
+ ? startSnapshot
+ : table.getSnapshotAsOfVersion(engine, resolvedEndVersion);
+ StructType startDeltaSchema = startSnapshot.getSchema();
StructType deltaSchema = endSnapshot.getSchema();
- if (deltaSchema == null) {
+ if (startDeltaSchema == null || deltaSchema == null) {
throw new IllegalStateException("Table schema is null.");
}
+ // All changes within the range are read using the table schema at the
end version, which is
+ // only supported if the range only spans additive schema changes (see
DeltaSchemaUtils). Fail
+ // early here if the schema at the start version can't be read using the
schema at the end
+ // version. CreateCDCReadTasksDoFn additionally verifies all schema
changes within the range
+ // while reading the commits of the range.
+ DeltaSchemaUtils.checkReadCompatible(
+ startDeltaSchema,
+ resolvedStartVersion,
+ deltaSchema,
+ resolvedStartVersion,
+ resolvedEndVersion);
Schema baseSchema = ReadRows.convertToBeamSchema(deltaSchema);
Schema publicBeamSchema = buildPublicBeamSchema(baseSchema,
getMetadataColumns());
diff --git
a/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/DeltaSchemaUtils.java
b/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/DeltaSchemaUtils.java
new file mode 100644
index 00000000000..98259645799
--- /dev/null
+++
b/sdks/java/io/delta/src/main/java/org/apache/beam/sdk/io/delta/DeltaSchemaUtils.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.delta;
+
+import io.delta.kernel.internal.util.ColumnMapping;
+import io.delta.kernel.types.ArrayType;
+import io.delta.kernel.types.DataType;
+import io.delta.kernel.types.MapType;
+import io.delta.kernel.types.StructField;
+import io.delta.kernel.types.StructType;
+
+/**
+ * Utilities for validating the schema changes of a Delta table within the
range of table versions
+ * that is read by {@link DeltaIO.ReadChanges}.
+ *
+ * <p>All changes within the range are read using the table schema at the end
version of the range.
+ * Similar to change data feed reads in Delta Lake, this is only supported if
the range only spans
+ * additive schema changes, i.e. if data written with the table schema at any
version within the
+ * range can be read using the table schema at the end version (see {@link
#isReadCompatible}).
+ */
+final class DeltaSchemaUtils {
+
+ private DeltaSchemaUtils() {}
+
+ /**
+ * Throws an {@link IllegalArgumentException} if data written with {@code
schema}, the table
+ * schema at version {@code schemaVersion}, can't be read using {@code
endSchema}, the table
+ * schema at the end version of the range of versions from {@code
startVersion} to {@code
+ * endVersion} that is read.
+ */
+ static void checkReadCompatible(
+ StructType schema,
+ long schemaVersion,
+ StructType endSchema,
+ long startVersion,
+ long endVersion) {
+ if (!isReadCompatible(schema, endSchema)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot read changes between versions %d and %d since the table
schema at version %d "
+ + "is not compatible with the table schema at the end
version %d, which is used "
+ + "to read all changes. Only additive schema changes, such
as adding nullable "
+ + "columns, are supported within the range of versions that
is read. "
+ + "Schema at version %d: %s, schema at version %d: %s.",
+ startVersion,
+ endVersion,
+ schemaVersion,
+ endVersion,
+ schemaVersion,
+ schema,
+ endVersion,
+ endSchema));
+ }
+ }
+
+ /**
+ * Returns whether data written with {@code dataSchema} can be read using
{@code readSchema}, i.e.
+ * whether {@code readSchema} only differs from {@code dataSchema} by
additive changes.
+ *
+ * <p>This mirrors the schema compatibility check of change data feed reads
in Delta Lake. The
+ * following changes are allowed:
+ *
+ * <ul>
+ * <li>Adding nullable fields, including nested fields. Records read from
data written with
+ * {@code dataSchema} get null values for these fields.
+ * <li>Relaxing the nullability of a field from non-nullable to nullable.
+ * <li>Reordering fields, since fields are matched by name.
+ * <li>Changing field metadata other than column mapping metadata (see
{@code
+ * hasSameColumnMapping()}). For example, column comments, column
default values, or
+ * identity column high-water marks (which writers update whenever
they insert rows into the
+ * table) may change. Such metadata does not affect the data or the
Beam schema of the
+ * emitted rows.
+ * </ul>
+ *
+ * <p>All other changes are not allowed. In particular, dropping or renaming
fields (including
+ * dropping and re-adding a field with the same name), changing data types,
tightening the
+ * nullability of fields, and adding non-nullable fields.
+ */
+ static boolean isReadCompatible(StructType dataSchema, StructType
readSchema) {
+ // Every field of the data schema must still exist in the read schema.
Fields are matched by
+ // their (case-sensitive) names since the names are part of the Beam
schema of the emitted rows.
+ // The positions of fields don't matter since data is read from the
columns of data files by
+ // name.
+ for (StructField dataField : dataSchema.fields()) {
+ int readFieldIndex = readSchema.indexOf(dataField.getName());
+ if (readFieldIndex < 0) {
+ // The field was dropped or renamed.
+ return false;
+ }
+ StructField readField = readSchema.at(readFieldIndex);
+ if (!hasSameColumnMapping(dataField, readField)
+ || !isNullabilityCompatible(dataField.isNullable(),
readField.isNullable())
+ || !isDataTypeReadCompatible(dataField.getDataType(),
readField.getDataType())) {
+ return false;
+ }
+ }
+ // Fields that only exist in the read schema were added. Data written with
the data schema
+ // doesn't contain values for them, so they are read as nulls and hence
must be nullable.
+ for (StructField readField : readSchema.fields()) {
+ if (dataSchema.indexOf(readField.getName()) < 0 &&
!readField.isNullable()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Returns whether data of type {@code dataType} can be read as type {@code
readType}. */
+ private static boolean isDataTypeReadCompatible(DataType dataType, DataType
readType) {
+ if (dataType instanceof StructType && readType instanceof StructType) {
+ return isReadCompatible((StructType) dataType, (StructType) readType);
+ } else if (dataType instanceof ArrayType && readType instanceof ArrayType)
{
+ // The nullability of array elements is part of the array type, not of
field metadata.
+ ArrayType dataArray = (ArrayType) dataType;
+ ArrayType readArray = (ArrayType) readType;
+ return isNullabilityCompatible(dataArray.containsNull(),
readArray.containsNull())
+ && isDataTypeReadCompatible(dataArray.getElementType(),
readArray.getElementType());
+ } else if (dataType instanceof MapType && readType instanceof MapType) {
+ // The nullability of map values is part of the map type, not of field
metadata.
+ MapType dataMap = (MapType) dataType;
+ MapType readMap = (MapType) readType;
+ return isNullabilityCompatible(dataMap.isValueContainsNull(),
readMap.isValueContainsNull())
+ && isDataTypeReadCompatible(dataMap.getKeyType(),
readMap.getKeyType())
+ && isDataTypeReadCompatible(dataMap.getValueType(),
readMap.getValueType());
+ } else {
+ // Primitive types must be the same since type changes (such as widening
an integer to a
+ // long) are not supported. Primitive types don't contain fields, and
hence don't carry field
+ // metadata, so they can be compared directly (for example, this
compares the precision and
+ // scale of decimal types). This also returns false if the two types are
of different kinds
+ // (e.g. a struct and an array).
+ return dataType.equals(readType);
+ }
+ }
+
+ /**
+ * Returns whether data with nullability {@code dataNullable} can be read
with nullability {@code
+ * readNullable}. Nullability may be relaxed but not tightened, since data
written with a nullable
+ * type may contain null values.
+ */
+ private static boolean isNullabilityCompatible(boolean dataNullable, boolean
readNullable) {
+ return readNullable || !dataNullable;
+ }
+
+ /**
+ * Returns whether two fields are read from the same column of the data
files.
+ *
+ * <p>If column mapping is enabled for a table, data files identify columns
by the physical names
+ * (and, in the 'id' column mapping mode, by the IDs) stored in the column
mapping metadata of the
+ * fields, instead of by their logical names. Although this is field
metadata, it has to be
+ * compared since all data files in the range of versions that is read are
read using the schema
+ * of the end version. For example, if a column was dropped and then
re-added with the same name
+ * and type, the logical schemas would match. However, data files written
before the drop don't
+ * contain the re-added column, so change records read from those files
would silently get null
+ * values for it.
+ */
+ private static boolean hasSameColumnMapping(StructField left, StructField
right) {
+ // getPhysicalName() returns the logical name of a field that doesn't have
a physical name,
+ // which is the case if column mapping is not enabled. Hence, enabling
column mapping on an
+ // existing table, which sets the physical names of the existing columns
to their logical
+ // names, is not considered to be a change.
+ if
(!ColumnMapping.getPhysicalName(left).equals(ColumnMapping.getPhysicalName(right)))
{
+ return false;
+ }
+ // Column IDs are never reused, so fields with different IDs belong to
different columns (for
+ // example, a column that was dropped and re-added) even if their physical
names match. IDs are
+ // only compared if both fields have one, since enabling column mapping on
an existing table
+ // assigns IDs to the existing columns.
+ String idKey = ColumnMapping.COLUMN_MAPPING_ID_KEY;
+ if (left.getMetadata().contains(idKey) &&
right.getMetadata().contains(idKey)) {
+ return ColumnMapping.getColumnId(left) ==
ColumnMapping.getColumnId(right);
+ }
+ return true;
+ }
+}
diff --git
a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaIOTest.java
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaIOTest.java
index 342c4b94761..d27c31d43e1 100644
---
a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaIOTest.java
+++
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaIOTest.java
@@ -25,6 +25,7 @@ import io.delta.kernel.types.BinaryType;
import io.delta.kernel.types.BooleanType;
import io.delta.kernel.types.DateType;
import io.delta.kernel.types.DoubleType;
+import io.delta.kernel.types.FieldMetadata;
import io.delta.kernel.types.FloatType;
import io.delta.kernel.types.IntegerType;
import io.delta.kernel.types.LongType;
@@ -1794,6 +1795,202 @@ public class DeltaIOTest {
readPipeline.run().waitUntilFinish();
}
+ @Test
+ public void testReadChangesNonAdditiveSchemaChangeBetweenVersionsThrows()
throws Exception {
+ File tableDir =
tempFolder.newFolder("delta-table-changes-schema-evolution-versions");
+ Engine engine = DefaultEngine.create(new
org.apache.hadoop.conf.Configuration());
+
+ // Version 0 creates the table with schema (name, age) and version 1 drops
the column 'age'.
+ DeltaWriteTestUtils.setupDroppedColumnTable(engine,
tableDir.getAbsolutePath());
+
+ org.apache.beam.sdk.Pipeline p = org.apache.beam.sdk.Pipeline.create();
+ IllegalArgumentException expandEx =
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ p.apply(
+ DeltaIO.readChanges()
+ .from(tableDir.getAbsolutePath())
+ .withStartVersion(0L)
+ .withEndVersion(1L)));
+ Assert.assertTrue(
+ expandEx
+ .getMessage()
+ .contains(
+ "the table schema at version 0 is not compatible with the
table schema at the end "
+ + "version 1"));
+
+ writePipeline
+ .apply(Create.of(tableDir.getAbsolutePath()))
+ .apply(ParDo.of(new CreateCDCReadTasksDoFn(null, 0L, null, 1L, null)));
+ org.apache.beam.sdk.Pipeline.PipelineExecutionException doFnEx =
+ Assert.assertThrows(
+ org.apache.beam.sdk.Pipeline.PipelineExecutionException.class,
+ () -> writePipeline.run().waitUntilFinish());
+ Assert.assertTrue(
+ doFnEx
+ .getCause()
+ .getMessage()
+ .contains(
+ "the table schema at version 0 is not compatible with the
table schema at the end "
+ + "version 1"));
+ }
+
+ @Test
+ public void testReadChangesNonAdditiveSchemaChangeBetweenTimestampsThrows()
throws Exception {
+ File tableDir =
tempFolder.newFolder("delta-table-changes-schema-evolution-timestamps");
+ Engine engine = DefaultEngine.create(new
org.apache.hadoop.conf.Configuration());
+
+ // Version 0 (committed at timestamp 100000000000L) creates the table with
schema (name, age)
+ // and version 1 (committed at timestamp 200000000000L) drops the column
'age'.
+ DeltaWriteTestUtils.setupDroppedColumnTable(engine,
tableDir.getAbsolutePath());
+
+ String startTimestamp =
java.time.Instant.ofEpochMilli(100000000000L).toString();
+ String endTimestamp =
java.time.Instant.ofEpochMilli(250000000000L).toString();
+
+ org.apache.beam.sdk.Pipeline p = org.apache.beam.sdk.Pipeline.create();
+ IllegalArgumentException expandEx =
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ p.apply(
+ DeltaIO.readChanges()
+ .from(tableDir.getAbsolutePath())
+ .withStartTimestamp(startTimestamp)
+ .withEndTimestamp(endTimestamp)));
+ Assert.assertTrue(
+ expandEx
+ .getMessage()
+ .contains(
+ "the table schema at version 0 is not compatible with the
table schema at the end "
+ + "version 1"));
+ }
+
+ @Test
+ public void testReadChangesColumnAddedAndDroppedWithinRangeThrows() throws
Exception {
+ File tableDir =
tempFolder.newFolder("delta-table-changes-column-added-and-dropped");
+ String tablePath = tableDir.getAbsolutePath();
+ Engine engine = DefaultEngine.create(new
org.apache.hadoop.conf.Configuration());
+
+ // Version 0 creates the table with schema (name), version 1 adds a column
'age', and version 2
+ // drops the column 'age' again. Hence, the schemas at the start and end
versions match.
+ DeltaWriteTestUtils.writeSchemaCommit(
+ engine, tablePath, 0L, 100000000000L, new StructType().add("name",
StringType.STRING));
+ StructType initialSchema =
+ Table.forPath(engine, tablePath).getLatestSnapshot(engine).getSchema();
+ DeltaWriteTestUtils.writeSchemaCommit(
+ engine, tablePath, 1L, 200000000000L, initialSchema.add("age",
IntegerType.INTEGER, true));
+ DeltaWriteTestUtils.writeSchemaCommit(engine, tablePath, 2L,
300000000000L, initialSchema);
+
+ // Expanding the transform succeeds since it only verifies the schemas at
the start and end
+ // versions. Planning the read tasks fails since it verifies all schema
changes within the
+ // range, and data written with the schema at version 1 can't be read
using the schema at the
+ // end version, which doesn't contain the column 'age'.
+ readPipeline.apply(
+
DeltaIO.readChanges().from(tablePath).withStartVersion(0L).withEndVersion(2L));
+ org.apache.beam.sdk.Pipeline.PipelineExecutionException doFnEx =
+ Assert.assertThrows(
+ org.apache.beam.sdk.Pipeline.PipelineExecutionException.class,
+ () -> readPipeline.run().waitUntilFinish());
+ Assert.assertTrue(
+ doFnEx
+ .getCause()
+ .getMessage()
+ .contains(
+ "the table schema at version 1 is not compatible with the
table schema at the end "
+ + "version 2"));
+ }
+
+ @Test
+ public void testReadChangesAddedNullableColumnBetweenVersions() throws
Exception {
+ File tableDir =
tempFolder.newFolder("delta-table-changes-added-nullable-column");
+ String tablePath = tableDir.getAbsolutePath();
+ Engine engine = DefaultEngine.create(new
org.apache.hadoop.conf.Configuration());
+
+ // Version 0 creates the table with schema (name) and inserts two rows.
+ Schema tableSchema = Schema.builder().addField("name",
Schema.FieldType.STRING).build();
+ DeltaWriteTestUtils.writeAppendCommit(
+ engine,
+ tablePath,
+ 0L,
+ 100000000000L,
+ new StructType().add("name", StringType.STRING),
+ java.util.Arrays.asList(
+ Row.withSchema(tableSchema).addValues("row-1").build(),
+ Row.withSchema(tableSchema).addValues("row-2").build()));
+
+ // Version 1 enables column mapping, which the Delta Lake Kernel API
requires for schema
+ // evolution. This is a metadata-only schema change.
+ DeltaWriteTestUtils.writeTablePropertiesCommit(
+ engine,
+ tablePath,
+ 1L,
+ 200000000000L,
+ Collections.singletonMap("delta.columnMapping.mode", "name"));
+
+ // Version 2 adds a nullable column 'age', which is an additive schema
change.
+ StructType latestSchema =
+ Table.forPath(engine, tablePath).getLatestSnapshot(engine).getSchema();
+ DeltaWriteTestUtils.writeSchemaCommit(
+ engine, tablePath, 2L, 300000000000L, latestSchema.add("age",
IntegerType.INTEGER, true));
+
+ // All changes are read using the schema at the end version, so the rows
that were inserted
+ // before the column 'age' was added get null values for it.
+ PCollection<Row> output =
+ readPipeline.apply(
+
DeltaIO.readChanges().from(tablePath).withStartVersion(0L).withEndVersion(2L));
+ Assert.assertEquals(
+ Schema.FieldType.INT32.withNullable(true),
output.getSchema().getField("age").getType());
+
+ PCollection<String> formattedOutput =
+ output.apply("Format ValueKind, Name, and Age", ParDo.of(new
FormatValueKindNameAndAge()));
+ PAssert.that(formattedOutput).containsInAnyOrder("INSERT:row-1:null",
"INSERT:row-2:null");
+
+ readPipeline.run().waitUntilFinish();
+ }
+
+ @Test
+ public void testReadChangesMetadataOnlySchemaChangeBetweenVersions() throws
Exception {
+ File tableDir =
tempFolder.newFolder("delta-table-changes-metadata-only-schema-change");
+ String tablePath = tableDir.getAbsolutePath();
+ Engine engine = DefaultEngine.create(new
org.apache.hadoop.conf.Configuration());
+
+ // Version 0 creates the table with schema (name).
+ DeltaWriteTestUtils.writeSchemaCommit(
+ engine, tablePath, 0L, 100000000000L, new StructType().add("name",
StringType.STRING));
+
+ // Version 1 only adds a comment to the existing column, which is a
metadata-only schema change.
+ // The existing field metadata of the column (column mapping ID and
physical name) is preserved.
+ Table table = Table.forPath(engine, tablePath);
+ StructField nameField =
table.getLatestSnapshot(engine).getSchema().get("name");
+ FieldMetadata metadataWithComment =
+ FieldMetadata.builder()
+ .fromMetadata(nameField.getMetadata())
+ .putString("comment", "The name.")
+ .build();
+ DeltaWriteTestUtils.writeSchemaCommit(
+ engine,
+ tablePath,
+ 1L,
+ 200000000000L,
+ new StructType().add("name", StringType.STRING, metadataWithComment));
+
+ // Sanity check that the Delta schemas of the two versions differ (in
their field metadata).
+ Assert.assertNotEquals(
+ table.getSnapshotAsOfVersion(engine, 0L).getSchema(),
+ table.getSnapshotAsOfVersion(engine, 1L).getSchema());
+
+ // Reading changes across the metadata-only schema change succeeds, both
when expanding the
+ // transform and when planning read tasks in CreateCDCReadTasksDoFn. The
output is empty since
+ // the table does not contain any data.
+ PCollection<Row> output =
+ readPipeline.apply(
+
DeltaIO.readChanges().from(tablePath).withStartVersion(0L).withEndVersion(1L));
+ PAssert.that(output).empty();
+
+ readPipeline.run().waitUntilFinish();
+ }
+
@Test
public void testS3SchemeRegistrationWithAnonymousCredentials() {
org.apache.hadoop.conf.Configuration conf = new
org.apache.hadoop.conf.Configuration();
@@ -1820,6 +2017,15 @@ public class DeltaIOTest {
}
}
+ private static final class FormatValueKindNameAndAge extends DoFn<Row,
String> {
+ @ProcessElement
+ public void process(
+ @Element Row row, ValueKind valueKind, OutputReceiver<String>
outputReceiver) {
+ outputReceiver.output(
+ valueKind.name() + ":" + row.getString("name") + ":" +
row.getInt32("age"));
+ }
+ }
+
private static final class FormatRowWithMetadata extends DoFn<Row, String> {
@ProcessElement
public void process(@Element Row row, OutputReceiver<String> out) {
diff --git
a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaSchemaUtilsTest.java
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaSchemaUtilsTest.java
new file mode 100644
index 00000000000..01b28ea311f
--- /dev/null
+++
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaSchemaUtilsTest.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.delta;
+
+import io.delta.kernel.internal.util.ColumnMapping;
+import io.delta.kernel.types.ArrayType;
+import io.delta.kernel.types.FieldMetadata;
+import io.delta.kernel.types.IntegerType;
+import io.delta.kernel.types.LongType;
+import io.delta.kernel.types.MapType;
+import io.delta.kernel.types.StringType;
+import io.delta.kernel.types.StructField;
+import io.delta.kernel.types.StructType;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link DeltaSchemaUtils}. */
+@RunWith(JUnit4.class)
+public class DeltaSchemaUtilsTest {
+
+ private static final StructField NAME =
+ new StructField("name", StringType.STRING, true,
columnMappingMetadata(1L, "col-1"));
+ private static final StructField AGE =
+ new StructField("age", IntegerType.INTEGER, true,
columnMappingMetadata(2L, "col-2"));
+ private static final StructType SCHEMA = new StructType().add(NAME).add(AGE);
+
+ @Test
+ public void testIsReadCompatibleIgnoresMetadataOnlyChanges() {
+ Assert.assertTrue(DeltaSchemaUtils.isReadCompatible(SCHEMA, SCHEMA));
+
+ StructType schema =
+ new StructType()
+ .add("name", StringType.STRING)
+ .add("nested", new StructType().add("value", LongType.LONG));
+
+ // Field metadata that does not affect the data, such as column comments,
is ignored. This also
+ // applies to nested fields.
+ FieldMetadata comment = FieldMetadata.builder().putString("comment", "A
comment.").build();
+ StructType schemaWithComments =
+ new StructType()
+ .add("name", StringType.STRING, comment)
+ .add("nested", new StructType().add("value", LongType.LONG,
comment), comment);
+ Assert.assertTrue(DeltaSchemaUtils.isReadCompatible(schema,
schemaWithComments));
+
+ // Enabling column mapping on an existing table assigns column IDs to the
existing columns and
+ // sets their physical names to their logical names, so data is still read
from the same
+ // columns.
+ StructType schemaWithColumnMapping =
+ new StructType()
+ .add("name", StringType.STRING, columnMappingMetadata(1L, "name"))
+ .add(
+ "nested",
+ new StructType().add("value", LongType.LONG,
columnMappingMetadata(3L, "value")),
+ columnMappingMetadata(2L, "nested"));
+ Assert.assertTrue(DeltaSchemaUtils.isReadCompatible(schema,
schemaWithColumnMapping));
+ }
+
+ @Test
+ public void testIsReadCompatibleAllowsAdditiveChanges() {
+ StructField city =
+ new StructField("city", StringType.STRING, true,
columnMappingMetadata(3L, "col-3"));
+
+ // Added nullable columns, at the end or in between existing columns.
+ Assert.assertTrue(DeltaSchemaUtils.isReadCompatible(SCHEMA,
SCHEMA.add(city)));
+ Assert.assertTrue(
+ DeltaSchemaUtils.isReadCompatible(SCHEMA, new
StructType().add(NAME).add(city).add(AGE)));
+ // Reordered columns, since columns are matched by name.
+ Assert.assertTrue(
+ DeltaSchemaUtils.isReadCompatible(SCHEMA, new
StructType().add(AGE).add(NAME)));
+ // Relaxed nullability.
+ Assert.assertTrue(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("name", StringType.STRING, false),
+ new StructType().add("name", StringType.STRING, true)));
+
+ // Added nullable nested field.
+ Assert.assertTrue(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("nested", new StructType().add("value",
LongType.LONG)),
+ new StructType()
+ .add(
+ "nested",
+ new StructType()
+ .add("value", LongType.LONG)
+ .add("other", StringType.STRING, true))));
+ // Relaxed nullability of array elements and map values.
+ Assert.assertTrue(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("array", new ArrayType(StringType.STRING,
false)),
+ new StructType().add("array", new ArrayType(StringType.STRING,
true))));
+ Assert.assertTrue(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("map", new MapType(StringType.STRING,
LongType.LONG, false)),
+ new StructType().add("map", new MapType(StringType.STRING,
LongType.LONG, true))));
+ }
+
+ @Test
+ public void testIsReadCompatibleRejectsNonAdditiveChanges() {
+ // Dropped column.
+ Assert.assertFalse(DeltaSchemaUtils.isReadCompatible(SCHEMA, new
StructType().add(NAME)));
+ // Renamed column. Renames preserve the column mapping metadata of the
column.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ SCHEMA,
+ new StructType().add("full_name", StringType.STRING,
NAME.getMetadata()).add(AGE)));
+ // Renamed column where only the case of the name changed, since names are
case-sensitive.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ SCHEMA, new StructType().add("Name", StringType.STRING,
NAME.getMetadata()).add(AGE)));
+ // Changed data type.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ SCHEMA, new StructType().add(NAME).add("age", LongType.LONG,
AGE.getMetadata())));
+ // Tightened nullability.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ SCHEMA,
+ new StructType().add(NAME).add("age", IntegerType.INTEGER, false,
AGE.getMetadata())));
+ // Added non-nullable column.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ SCHEMA,
+ SCHEMA.add("city", StringType.STRING, false,
columnMappingMetadata(3L, "col-3"))));
+
+ // A column that was dropped and re-added with the same name and type gets
a new column ID and
+ // physical name.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ SCHEMA,
+ new StructType()
+ .add(NAME)
+ .add("age", IntegerType.INTEGER, columnMappingMetadata(3L,
"col-3"))));
+ // A re-added column is detected through its new column ID even if it
reuses the physical name.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ SCHEMA,
+ new StructType()
+ .add(NAME)
+ .add("age", IntegerType.INTEGER, columnMappingMetadata(3L,
"col-2"))));
+ // A column that was dropped and re-added after enabling column mapping is
detected through its
+ // physical name, which differs from the logical name that was used to
read the column before.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("age", IntegerType.INTEGER),
+ new StructType().add("age", IntegerType.INTEGER,
columnMappingMetadata(2L, "col-2"))));
+
+ // Non-additive changes of nested types.
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType()
+ .add(
+ "nested",
+ new StructType().add("value", LongType.LONG).add("other",
StringType.STRING)),
+ new StructType().add("nested", new StructType().add("value",
LongType.LONG))));
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("nested", new StructType().add("value",
IntegerType.INTEGER)),
+ new StructType().add("nested", new StructType().add("value",
LongType.LONG))));
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("array", new ArrayType(IntegerType.INTEGER,
true)),
+ new StructType().add("array", new ArrayType(LongType.LONG,
true))));
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("array", new ArrayType(StringType.STRING,
true)),
+ new StructType().add("array", new ArrayType(StringType.STRING,
false))));
+ Assert.assertFalse(
+ DeltaSchemaUtils.isReadCompatible(
+ new StructType().add("map", new MapType(StringType.STRING,
LongType.LONG, true)),
+ new StructType().add("map", new MapType(StringType.STRING,
LongType.LONG, false))));
+ }
+
+ @Test
+ public void testCheckReadCompatible() {
+ // Does not throw for additive changes.
+ DeltaSchemaUtils.checkReadCompatible(
+ SCHEMA,
+ 1L,
+ SCHEMA.add("city", StringType.STRING, true, columnMappingMetadata(3L,
"col-3")),
+ 0L,
+ 2L);
+
+ IllegalArgumentException e =
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ DeltaSchemaUtils.checkReadCompatible(
+ SCHEMA, 1L, new StructType().add(NAME), 0L, 2L));
+ Assert.assertTrue(
+ e.getMessage()
+ .startsWith(
+ "Cannot read changes between versions 0 and 2 since the table
schema at version 1 "
+ + "is not compatible with the table schema at the end
version 2"));
+ }
+
+ /** Returns field metadata containing the given column mapping ID and
physical name. */
+ private static FieldMetadata columnMappingMetadata(long columnId, String
physicalName) {
+ return FieldMetadata.builder()
+ .putLong(ColumnMapping.COLUMN_MAPPING_ID_KEY, columnId)
+ .putString(ColumnMapping.COLUMN_MAPPING_PHYSICAL_NAME_KEY,
physicalName)
+ .build();
+ }
+}
diff --git
a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java
index 5ba483d56ce..536418be0e7 100644
---
a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java
+++
b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java
@@ -32,6 +32,7 @@ import io.delta.kernel.internal.data.GenericRow;
import io.delta.kernel.types.BooleanType;
import io.delta.kernel.types.DataType;
import io.delta.kernel.types.DateType;
+import io.delta.kernel.types.IntegerType;
import io.delta.kernel.types.LongType;
import io.delta.kernel.types.MapType;
import io.delta.kernel.types.StringType;
@@ -417,6 +418,101 @@ public final class DeltaWriteTestUtils {
}
}
+ /**
+ * Writes a metadata-only Delta commit (i.e. a commit without any data
files) that sets the schema
+ * of the table.
+ *
+ * <p>If {@code expectedVersion} is 0, a new table is created with the given
schema, with Change
+ * Data Feed and column mapping (name mode) enabled. Otherwise, the schema
of the existing table
+ * is evolved to the given schema.
+ *
+ * <p>Note on why this is metadata-only: The Delta Lake Kernel API only
supports schema evolution
+ * for tables that have column mapping enabled, but it does not yet support
writing data into such
+ * tables.
+ *
+ * <p>When evolving the schema, the given schema must preserve the field
metadata (column mapping
+ * IDs and physical names) of existing columns, for example, by deriving the
new schema from the
+ * schema of the latest snapshot. Fields without such metadata are
considered to be new columns.
+ *
+ * @param engine the Delta Lake {@link Engine} instance to use
+ * @param tablePath the path of the Delta table to write to
+ * @param expectedVersion the expected version of the commit to be created
+ * @param timestamp the timestamp of the commit file
+ * @param deltaSchema the new schema of the Delta table
+ * @throws Exception if any error occurs during commit
+ */
+ public static void writeSchemaCommit(
+ Engine engine, String tablePath, long expectedVersion, long timestamp,
StructType deltaSchema)
+ throws Exception {
+
+ Table table = Table.forPath(engine, tablePath);
+ TransactionBuilder txnBuilder;
+ if (expectedVersion == 0) {
+ Map<String, String> tableProperties = new HashMap<>();
+ tableProperties.put("delta.enableChangeDataFeed", "true");
+ tableProperties.put("delta.columnMapping.mode", "name");
+ txnBuilder =
+ table
+ .createTransactionBuilder(engine, "DeltaTestUtils",
Operation.CREATE_TABLE)
+ .withTableProperties(engine, tableProperties);
+ } else {
+ txnBuilder =
+ table.createTransactionBuilder(engine, "DeltaTestUtils",
Operation.MANUAL_UPDATE);
+ }
+ Transaction txn = txnBuilder.withSchema(engine, deltaSchema).build(engine);
+
+ TransactionCommitResult result = txn.commit(engine,
CloseableIterable.emptyIterable());
+ org.junit.Assert.assertEquals(expectedVersion, result.getVersion());
+ setCommitFileTimestamp(tablePath, expectedVersion, timestamp);
+ }
+
+ /**
+ * Writes a metadata-only Delta commit (i.e. a commit without any data
files) that sets the given
+ * table properties of an existing table.
+ *
+ * <p>For example, this can be used to enable column mapping on an existing
table by setting
+ * {@code delta.columnMapping.mode} to {@code name}.
+ *
+ * @param engine the Delta Lake {@link Engine} instance to use
+ * @param tablePath the path of the Delta table to write to
+ * @param expectedVersion the expected version of the commit to be created
+ * @param timestamp the timestamp of the commit file
+ * @param tableProperties the table properties to set
+ * @throws Exception if any error occurs during commit
+ */
+ public static void writeTablePropertiesCommit(
+ Engine engine,
+ String tablePath,
+ long expectedVersion,
+ long timestamp,
+ Map<String, String> tableProperties)
+ throws Exception {
+
+ Transaction txn =
+ Table.forPath(engine, tablePath)
+ .createTransactionBuilder(engine, "DeltaTestUtils",
Operation.MANUAL_UPDATE)
+ .withTableProperties(engine, tableProperties)
+ .build(engine);
+
+ TransactionCommitResult result = txn.commit(engine,
CloseableIterable.emptyIterable());
+ org.junit.Assert.assertEquals(expectedVersion, result.getVersion());
+ setCommitFileTimestamp(tablePath, expectedVersion, timestamp);
+ }
+
+ /**
+ * Sets the modification time of the commit file of the given version, which
determines the
+ * timestamp of the commit, to the given timestamp. This is only supported
for local tables.
+ */
+ private static void setCommitFileTimestamp(String tablePath, long version,
long timestamp) {
+ if (!tablePath.startsWith("gs://")
+ && !tablePath.startsWith("s3://")
+ && !tablePath.startsWith("hdfs://")) {
+ File commitFile =
+ new File(new File(tablePath, "_delta_log"),
String.format("%020d.json", version));
+ commitFile.setLastModified(timestamp);
+ }
+ }
+
/**
* Sets up a Delta table with two commit versions containing test rows.
*
@@ -445,4 +541,33 @@ public final class DeltaWriteTestUtils {
return java.util.Arrays.asList(row1, row2, row3);
}
+
+ /**
+ * Sets up a Delta table with two metadata-only commit versions where a
column is dropped, which
+ * is a non-additive schema change.
+ *
+ * <p>Version 0 is committed at timestamp 100000000000L and creates the
table with schema {@code
+ * (name STRING, age INTEGER)}. Version 1 is committed at timestamp
200000000000L and drops the
+ * {@code age} column, resulting in schema {@code (name STRING)}.
+ *
+ * @param engine the Delta Lake {@link Engine} instance to use
+ * @param tablePath the path of the Delta table to create
+ * @throws Exception if any error occurs during commit
+ */
+ static void setupDroppedColumnTable(Engine engine, String tablePath) throws
Exception {
+ // Commit version 0
+ writeSchemaCommit(
+ engine,
+ tablePath,
+ 0L,
+ 100000000000L,
+ new StructType().add("name", StringType.STRING).add("age",
IntegerType.INTEGER));
+
+ // Commit version 1. The new schema keeps the existing 'name' field of the
latest schema of the
+ // table to preserve its column mapping metadata.
+ StructType latestSchema =
+ Table.forPath(engine, tablePath).getLatestSnapshot(engine).getSchema();
+ writeSchemaCommit(
+ engine, tablePath, 1L, 200000000000L, new
StructType().add(latestSchema.get("name")));
+ }
}