diff --git a/xtable-api/src/main/java/org/apache/xtable/model/InternalTable.java b/xtable-api/src/main/java/org/apache/xtable/model/InternalTable.java
index 4fadbb07d..852538ec9 100644
--- a/xtable-api/src/main/java/org/apache/xtable/model/InternalTable.java
+++ b/xtable-api/src/main/java/org/apache/xtable/model/InternalTable.java
@@ -52,4 +52,15 @@ public class InternalTable {
Instant latestCommitTime;
// Path to latest metadata
String latestMetadataPath;
+ /**
+ * Identifies the source-table operation this state was derived from, or null when the source does
+ * not supply one. Written by the source's table extractor and carried into the target's metadata
+ * by {@link org.apache.xtable.spi.sync.TableFormatSync}, so that a target reading its own
+ * metadata back can tell which source operation it last applied.
+ *
+ *
The contents are specific to the source format. The Hudi extractor writes a serialised Hudi
+ * instant, which only Hudi-aware code should parse; every other target must treat this as an
+ * opaque string.
+ */
+ String latestTableOperationIdentifier;
}
diff --git a/xtable-api/src/main/java/org/apache/xtable/model/metadata/TableSyncMetadata.java b/xtable-api/src/main/java/org/apache/xtable/model/metadata/TableSyncMetadata.java
index d8c707916..709e4c8f7 100644
--- a/xtable-api/src/main/java/org/apache/xtable/model/metadata/TableSyncMetadata.java
+++ b/xtable-api/src/main/java/org/apache/xtable/model/metadata/TableSyncMetadata.java
@@ -28,6 +28,7 @@
import lombok.Value;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@@ -46,6 +47,10 @@ public class TableSyncMetadata {
new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ // A reader on an older version has to tolerate fields a newer writer added, since this
+ // blob is persisted in target-table metadata and is read back by whichever version
+ // happens to open the table next.
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
/** Property name for the XTABLE metadata in the table metadata/properties */
@@ -56,6 +61,12 @@ public class TableSyncMetadata {
int version;
String sourceTableFormat;
String sourceIdentifier;
+ /**
+ * Identifies the source-table operation this sync corresponds to. The contents are specific to
+ * the source format, so a target must treat them as opaque. See {@link
+ * org.apache.xtable.model.InternalTable#latestTableOperationIdentifier}.
+ */
+ String latestTableOperationIdentifier;
/**
* @deprecated Use {@link #of(Instant, List, String, String)} instead. This method exists for
@@ -64,7 +75,7 @@ public class TableSyncMetadata {
@Deprecated
public static TableSyncMetadata of(
Instant lastInstantSynced, List instantsToConsiderForNextSync) {
- return TableSyncMetadata.of(lastInstantSynced, instantsToConsiderForNextSync, null, null);
+ return TableSyncMetadata.of(lastInstantSynced, instantsToConsiderForNextSync, null, null, null);
}
public static TableSyncMetadata of(
@@ -72,12 +83,27 @@ public static TableSyncMetadata of(
List instantsToConsiderForNextSync,
String sourceTableFormat,
String sourceIdentifier) {
+ return TableSyncMetadata.of(
+ lastInstantSynced,
+ instantsToConsiderForNextSync,
+ sourceTableFormat,
+ sourceIdentifier,
+ null);
+ }
+
+ public static TableSyncMetadata of(
+ Instant lastInstantSynced,
+ List instantsToConsiderForNextSync,
+ String sourceTableFormat,
+ String sourceIdentifier,
+ String latestTableOperationIdentifier) {
return new TableSyncMetadata(
lastInstantSynced,
instantsToConsiderForNextSync,
CURRENT_VERSION,
sourceTableFormat,
- sourceIdentifier);
+ sourceIdentifier,
+ latestTableOperationIdentifier);
}
public String toJson() {
diff --git a/xtable-api/src/main/java/org/apache/xtable/spi/sync/TableFormatSync.java b/xtable-api/src/main/java/org/apache/xtable/spi/sync/TableFormatSync.java
index ed5ce80f4..b4974bf4c 100644
--- a/xtable-api/src/main/java/org/apache/xtable/spi/sync/TableFormatSync.java
+++ b/xtable-api/src/main/java/org/apache/xtable/spi/sync/TableFormatSync.java
@@ -168,7 +168,8 @@ private SyncResult getSyncResult(
tableState.getLatestCommitTime(),
pendingCommits,
tableState.getTableFormat(),
- sourceIdentifier);
+ sourceIdentifier,
+ tableState.getLatestTableOperationIdentifier());
conversionTarget.syncMetadata(latestState);
// sync schema updates
conversionTarget.syncSchema(tableState.getReadSchema());
diff --git a/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java b/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java
index f1e7bbb6f..c502209c8 100644
--- a/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java
+++ b/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java
@@ -18,20 +18,23 @@
package org.apache.xtable.conversion;
+import java.util.Iterator;
import java.util.Properties;
+import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
+import lombok.extern.log4j.Log4j2;
import org.apache.hadoop.conf.Configuration;
import org.apache.xtable.delta.DeltaConversionTargetConfig;
import org.apache.xtable.exception.NotSupportedException;
-import org.apache.xtable.kernel.DeltaKernelConversionTarget;
import org.apache.xtable.model.storage.TableFormat;
import org.apache.xtable.spi.sync.ConversionTarget;
+@Log4j2
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ConversionTargetFactory {
private static final ConversionTargetFactory INSTANCE = new ConversionTargetFactory();
@@ -87,7 +90,21 @@ public ConversionTarget createConversionTargetForName(
TableFormat.DELTA.equalsIgnoreCase(tableFormatName)
&& DeltaConversionTargetConfig.fromProperties(properties).isUseKernel();
ServiceLoader loader = ServiceLoader.load(ConversionTarget.class);
- for (ConversionTarget target : loader) {
+ Iterator iterator = loader.iterator();
+ while (true) {
+ ConversionTarget target;
+ try {
+ // hasNext() resolves provider classes lazily, so it throws too and has to be guarded.
+ if (!iterator.hasNext()) {
+ break;
+ }
+ target = iterator.next();
+ } catch (ServiceConfigurationError | LinkageError error) {
+ // A registered target whose engine library is absent. Skip it so a subset of engines works;
+ // a missing engine for the requested format still fails below as NotSupportedException.
+ log.warn("Skipping a ConversionTarget whose engine library is not on the classpath", error);
+ continue;
+ }
if (target.getTableFormat().equalsIgnoreCase(tableFormatName)
&& isDeltaKernelTarget(target) == useKernel) {
return target;
@@ -96,7 +113,10 @@ && isDeltaKernelTarget(target) == useKernel) {
throw new NotSupportedException("Target format is not yet supported: " + tableFormatName);
}
+ private static final String DELTA_KERNEL_TARGET_CLASS =
+ "org.apache.xtable.kernel.DeltaKernelConversionTarget";
+
private static boolean isDeltaKernelTarget(ConversionTarget target) {
- return target instanceof DeltaKernelConversionTarget;
+ return DELTA_KERNEL_TARGET_CLASS.equals(target.getClass().getName());
}
}
diff --git a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiDataFileExtractor.java b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiDataFileExtractor.java
index a9f2bacc4..a694dddaa 100644
--- a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiDataFileExtractor.java
+++ b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiDataFileExtractor.java
@@ -25,7 +25,9 @@
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -57,6 +59,8 @@
import org.apache.hudi.common.table.view.TableFileSystemView;
import org.apache.hudi.hadoop.fs.HadoopFSUtils;
import org.apache.hudi.metadata.HoodieTableMetadata;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.storage.StoragePathInfo;
import org.apache.xtable.collectors.CustomCollectors;
import org.apache.xtable.exception.NotSupportedException;
@@ -112,6 +116,21 @@ public HudiDataFileExtractor(
this.fileStatsExtractor = hudiFileStatsExtractor;
}
+ public HudiDataFileExtractor(
+ HoodieTableMetaClient metaClient,
+ PathBasedPartitionValuesExtractor hudiPartitionValuesExtractor,
+ HudiFileStatsExtractor hudiFileStatsExtractor,
+ FileSystemViewManager fileSystemViewManager) {
+ this.engineContext = new HoodieLocalEngineContext(metaClient.getStorageConf());
+ this.metadataConfig = HoodieMetadataConfig.newBuilder().enable(false).build();
+ this.basePath = HadoopFSUtils.convertToHadoopPath(metaClient.getBasePath());
+ this.tableMetadata = null;
+ this.fileSystemViewManager = fileSystemViewManager;
+ this.metaClient = metaClient;
+ this.partitionValuesExtractor = hudiPartitionValuesExtractor;
+ this.fileStatsExtractor = hudiFileStatsExtractor;
+ }
+
public List getFilesCurrentState(InternalTable table) {
try {
List allPartitionPaths =
@@ -145,6 +164,121 @@ public InternalFilesDiff getDiffForCommit(
return InternalFilesDiff.builder().filesAdded(filesAdded).filesRemoved(filesRemoved).build();
}
+ /**
+ * Derives the file diff from the metadata of the commit being written, rather than by comparing
+ * two committed states as {@link #getDiffForCommit(HoodieInstant, InternalTable, HoodieInstant,
+ * HoodieTimeline)} does. Requires the constructor taking a {@link FileSystemViewManager}, since
+ * it reads the live file system view. Log files are skipped, so merge-on-read updates are not
+ * represented.
+ */
+ public InternalFilesDiff getDiffFromCommitMetadata(
+ InternalTable table, HoodieCommitMetadata commitMetadata, HoodieInstant commit) {
+ SyncableFileSystemView fsView = fileSystemViewManager.getFileSystemView(metaClient);
+ List filesAddedWithoutStats = new ArrayList<>();
+ List filesToRemove = new ArrayList<>();
+ Map fullPathInfo =
+ commitMetadata.getFullPathToInfo(metaClient.getStorage(), basePath.toString());
+ commitMetadata
+ .getPartitionToWriteStats()
+ .forEach(
+ (partitionPath, writeStats) -> {
+ List partitionValues =
+ partitionValuesExtractor.extractPartitionValues(
+ table.getPartitioningFields(), partitionPath);
+ Map currentBaseFilesInPartition =
+ fsView
+ .getLatestBaseFiles(partitionPath)
+ .collect(Collectors.toMap(HoodieBaseFile::getFileId, Function.identity()));
+ for (HoodieWriteStat writeStat : writeStats) {
+ if (FSUtils.isLogFile(new StoragePath(writeStat.getPath()))) {
+ continue;
+ }
+ StoragePath baseFileFullPath =
+ FSUtils.constructAbsolutePath(metaClient.getBasePath(), writeStat.getPath());
+ if (FSUtils.getCommitTimeWithFullPath(baseFileFullPath.toString())
+ .equals(commit.requestedTime())) {
+ // getFullPathToInfo keys the map by the absolute path, not the file name
+ StoragePathInfo pathInfo = fullPathInfo.get(baseFileFullPath.toString());
+ if (pathInfo == null) {
+ throw new ReadException(
+ "Commit metadata has no file info for base file " + baseFileFullPath);
+ }
+ filesAddedWithoutStats.add(
+ buildFileWithoutStats(partitionValues, new HoodieBaseFile(pathInfo)));
+ }
+ if (currentBaseFilesInPartition.containsKey(writeStat.getFileId())) {
+ filesToRemove.add(
+ buildFileWithoutStats(
+ partitionValues, currentBaseFilesInPartition.get(writeStat.getFileId())));
+ }
+ }
+ });
+ List filesAdded =
+ fileStatsExtractor
+ .addStatsToFiles(tableMetadata, filesAddedWithoutStats.stream(), table.getReadSchema())
+ .collect(Collectors.toList());
+ return InternalFilesDiff.builder().filesAdded(filesAdded).filesRemoved(filesToRemove).build();
+ }
+
+ /**
+ * Replace-commit counterpart of {@link #getDiffFromCommitMetadata}. Files the replace commit
+ * supersedes are reported as removed, files it wrote as added.
+ */
+ public InternalFilesDiff getDiffFromReplaceCommitMetadata(
+ InternalTable table,
+ HoodieReplaceCommitMetadata replaceCommitMetadata,
+ HoodieInstant commit) {
+ SyncableFileSystemView fsView = fileSystemViewManager.getFileSystemView(metaClient);
+ List filesAddedWithoutStats = new ArrayList<>();
+ List filesToRemove = new ArrayList<>();
+ replaceCommitMetadata
+ .getPartitionToReplaceFileIds()
+ .forEach(
+ (partitionPath, fileIds) -> {
+ List partitionValues =
+ partitionValuesExtractor.extractPartitionValues(
+ table.getPartitioningFields(), partitionPath);
+ Map currentBaseFilesInPartition =
+ fsView
+ .getLatestBaseFiles(partitionPath)
+ .collect(Collectors.toMap(HoodieBaseFile::getFileId, Function.identity()));
+ filesToRemove.addAll(
+ fileIds.stream()
+ .map(
+ fileId ->
+ buildFileWithoutStats(
+ partitionValues, currentBaseFilesInPartition.get(fileId)))
+ .collect(Collectors.toList()));
+ });
+ replaceCommitMetadata
+ .getPartitionToWriteStats()
+ .forEach(
+ (partitionPath, writeStats) -> {
+ List partitionValues =
+ partitionValuesExtractor.extractPartitionValues(
+ table.getPartitioningFields(), partitionPath);
+ filesAddedWithoutStats.addAll(
+ writeStats.stream()
+ .map(
+ writeStat ->
+ FSUtils.constructAbsolutePath(
+ metaClient.getBasePath(), writeStat.getPath())
+ .toString())
+ .filter(
+ baseFileFullPath ->
+ FSUtils.getCommitTimeWithFullPath(baseFileFullPath)
+ .equals(commit.requestedTime()))
+ .map(HoodieBaseFile::new)
+ .map(hoodieBaseFile -> buildFileWithoutStats(partitionValues, hoodieBaseFile))
+ .collect(Collectors.toList()));
+ });
+ List filesAdded =
+ fileStatsExtractor
+ .addStatsToFiles(tableMetadata, filesAddedWithoutStats.stream(), table.getReadSchema())
+ .collect(Collectors.toList());
+ return InternalFilesDiff.builder().filesAdded(filesAdded).filesRemoved(filesToRemove).build();
+ }
+
private AddedAndRemovedFiles getAddedAndRemovedPartitionInfo(
HoodieTimeline timeline,
HoodieInstant instant,
diff --git a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiIncrementalTableChangeExtractor.java b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiIncrementalTableChangeExtractor.java
new file mode 100644
index 000000000..6f15027b8
--- /dev/null
+++ b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiIncrementalTableChangeExtractor.java
@@ -0,0 +1,91 @@
+/*
+ * 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.xtable.hudi;
+
+import java.util.Collections;
+import java.util.Iterator;
+
+import lombok.Value;
+
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieReplaceCommitMetadata;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+
+import org.apache.xtable.model.IncrementalTableChanges;
+import org.apache.xtable.model.InternalTable;
+import org.apache.xtable.model.TableChange;
+import org.apache.xtable.model.storage.InternalFilesDiff;
+
+/**
+ * Computes {@link org.apache.xtable.model.IncrementalTableChanges} between current state of the
+ * table and new completed instant added to the timeline.
+ */
+@Value
+public class HudiIncrementalTableChangeExtractor {
+ HoodieTableMetaClient metaClient;
+ HudiTableExtractor tableExtractor;
+ HudiDataFileExtractor dataFileExtractor;
+
+ public IncrementalTableChanges extractTableChanges(
+ HoodieCommitMetadata commitMetadata, HoodieInstant completedInstant) {
+ InternalTable internalTable =
+ tableExtractor.table(metaClient, commitMetadata, completedInstant);
+ InternalFilesDiff dataFilesDiff;
+ if (commitMetadata instanceof HoodieReplaceCommitMetadata) {
+ dataFilesDiff =
+ dataFileExtractor.getDiffFromReplaceCommitMetadata(
+ internalTable, (HoodieReplaceCommitMetadata) commitMetadata, completedInstant);
+ } else {
+ dataFilesDiff =
+ dataFileExtractor.getDiffFromCommitMetadata(
+ internalTable, commitMetadata, completedInstant);
+ }
+
+ Iterator tableChangeIterator =
+ Collections.singleton(
+ TableChange.builder()
+ .tableAsOfChange(internalTable)
+ .filesDiff(dataFilesDiff)
+ .sourceIdentifier(completedInstant.getCompletionTime())
+ .build())
+ .iterator();
+ return IncrementalTableChanges.builder()
+ .tableChanges(tableChangeIterator)
+ .pendingCommits(Collections.emptyList())
+ .build();
+ }
+
+ public IncrementalTableChanges extractTableChanges(HoodieInstant completedInstant) {
+ InternalTable internalTable = tableExtractor.table(metaClient, completedInstant);
+ Iterator tableChangeIterator =
+ Collections.singleton(
+ TableChange.builder()
+ .tableAsOfChange(internalTable)
+ .filesDiff(
+ InternalFilesDiff.from(Collections.emptyList(), Collections.emptyList()))
+ .sourceIdentifier(completedInstant.getCompletionTime())
+ .build())
+ .iterator();
+ return IncrementalTableChanges.builder()
+ .tableChanges(tableChangeIterator)
+ .pendingCommits(Collections.emptyList())
+ .build();
+ }
+}
diff --git a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiInstantUtils.java b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiInstantUtils.java
index 7ed9c49ce..17d013ecf 100644
--- a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiInstantUtils.java
+++ b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiInstantUtils.java
@@ -34,7 +34,7 @@
import org.apache.xtable.model.exception.ParseException;
-class HudiInstantUtils {
+public class HudiInstantUtils {
private static final ZoneId ZONE_ID = ZoneId.of("UTC");
// Unfortunately millisecond format is not parsable as is
@@ -54,7 +54,7 @@ class HudiInstantUtils {
* @param timestamp input commit timestamp
* @return timestamp parsed as Instant
*/
- static Instant parseFromInstantTime(String timestamp) {
+ public static Instant parseFromInstantTime(String timestamp) {
try {
String timestampInMillis = timestamp;
if (isSecondGranularity(timestamp)) {
@@ -70,7 +70,7 @@ static Instant parseFromInstantTime(String timestamp) {
}
}
- static String convertInstantToCommit(Instant instant) {
+ public static String convertInstantToCommit(Instant instant) {
LocalDateTime instantTime = instant.atZone(ZONE_ID).toLocalDateTime();
return HoodieInstantTimeGenerator.getInstantFromTemporalAccessor(instantTime);
}
diff --git a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiTableExtractor.java b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiTableExtractor.java
index 3a75f2bd3..08ef3a6d1 100644
--- a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiTableExtractor.java
+++ b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiTableExtractor.java
@@ -18,6 +18,8 @@
package org.apache.xtable.hudi;
+import static org.apache.hudi.common.model.HoodieCommitMetadata.SCHEMA_KEY;
+
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -25,13 +27,24 @@
import javax.inject.Singleton;
+import lombok.SneakyThrows;
+
import org.apache.avro.Schema;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.HoodieSchemaUtils;
import org.apache.hudi.common.table.HoodieTableMetaClient;
import org.apache.hudi.common.table.TableSchemaResolver;
import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.dto.InstantDTO;
import org.apache.hudi.common.util.Option;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
import org.apache.xtable.exception.SchemaExtractorException;
import org.apache.xtable.model.InternalTable;
import org.apache.xtable.model.schema.InternalField;
@@ -47,6 +60,11 @@
*/
@Singleton
public class HudiTableExtractor {
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ .setSerializationInclusion(JsonInclude.Include.NON_NULL);
private final HudiSchemaExtractor schemaExtractor;
private final SourcePartitionSpecExtractor partitionSpecExtractor;
@@ -58,18 +76,7 @@ public HudiTableExtractor(
}
public InternalTable table(HoodieTableMetaClient metaClient, HoodieInstant commit) {
- TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(metaClient);
- InternalSchema canonicalSchema;
- Schema avroSchema;
- try {
- avroSchema = tableSchemaResolver.getTableSchema(commit.requestedTime()).toAvroSchema();
- canonicalSchema = schemaExtractor.schema(avroSchema);
- } catch (Exception e) {
- throw new SchemaExtractorException(
- String.format(
- "Failed to convert table %s schema", metaClient.getTableConfig().getTableName()),
- e);
- }
+ InternalSchema canonicalSchema = getCanonicalSchemaFromTimeline(metaClient, commit);
List partitionFields = partitionSpecExtractor.spec(canonicalSchema);
List recordKeyFields = getRecordKeyFields(metaClient, canonicalSchema);
if (!recordKeyFields.isEmpty()) {
@@ -88,9 +95,82 @@ public InternalTable table(HoodieTableMetaClient metaClient, HoodieInstant commi
.readSchema(canonicalSchema)
.latestMetadataPath(metaClient.getMetaPath().toString())
.latestCommitTime(HudiInstantUtils.parseFromInstantTime(commit.requestedTime()))
+ .latestTableOperationIdentifier(generateTableOperationId(commit))
+ .build();
+ }
+
+ public InternalTable table(
+ HoodieTableMetaClient metaClient,
+ HoodieCommitMetadata commitMetadata,
+ HoodieInstant completedInstant) {
+ InternalSchema canonicalSchema =
+ getCanonicalSchemaFromCommitMetadata(metaClient, commitMetadata, completedInstant);
+ List partitionFields = partitionSpecExtractor.spec(canonicalSchema);
+ List recordKeyFields = getRecordKeyFields(metaClient, canonicalSchema);
+ if (!recordKeyFields.isEmpty()) {
+ canonicalSchema = canonicalSchema.toBuilder().recordKeyFields(recordKeyFields).build();
+ }
+ DataLayoutStrategy dataLayoutStrategy =
+ partitionFields.size() > 0
+ ? DataLayoutStrategy.DIR_HIERARCHY_PARTITION_VALUES
+ : DataLayoutStrategy.FLAT;
+ return InternalTable.builder()
+ .tableFormat(TableFormat.HUDI)
+ .basePath(metaClient.getBasePath().toString())
+ .name(metaClient.getTableConfig().getTableName())
+ .layoutStrategy(dataLayoutStrategy)
+ .partitioningFields(partitionFields)
+ .readSchema(canonicalSchema)
+ .latestMetadataPath(metaClient.getMetaPath().toString())
+ // Completion time, not requested time as the timeline-based overload uses. A pluggable
+ // table format is called once an instant completes and orders by completion time, so this
+ // is the clock its incremental-sync decision has to compare against.
+ .latestCommitTime(
+ HudiInstantUtils.parseFromInstantTime(completedInstant.getCompletionTime()))
+ .latestTableOperationIdentifier(generateTableOperationId(completedInstant))
.build();
}
+ private InternalSchema getCanonicalSchemaFromCommitMetadata(
+ HoodieTableMetaClient metaClient, HoodieCommitMetadata commitMetadata, HoodieInstant commit) {
+ String writerSchemaJson = commitMetadata.getExtraMetadata().get(SCHEMA_KEY);
+ if (writerSchemaJson == null) {
+ throw new SchemaExtractorException(
+ String.format(
+ "Commit metadata for instant %s of table %s carries no writer schema",
+ commit, metaClient.getTableConfig().getTableName()));
+ }
+ boolean withOperationField = false;
+ try {
+ HoodieSchema writerSchema = HoodieSchema.parse(writerSchemaJson);
+ return schemaExtractor.schema(
+ HoodieSchemaUtils.addMetadataFields(writerSchema, withOperationField).toAvroSchema());
+ } catch (Exception e) {
+ throw new SchemaExtractorException(
+ String.format(
+ "Unable to read the writer schema for instant %s of table %s",
+ commit, metaClient.getTableConfig().getTableName()),
+ e);
+ }
+ }
+
+ private InternalSchema getCanonicalSchemaFromTimeline(
+ HoodieTableMetaClient metaClient, HoodieInstant commit) {
+ TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(metaClient);
+ InternalSchema canonicalSchema;
+ Schema avroSchema;
+ try {
+ avroSchema = tableSchemaResolver.getTableSchema(commit.requestedTime()).toAvroSchema();
+ canonicalSchema = schemaExtractor.schema(avroSchema);
+ } catch (Exception e) {
+ throw new SchemaExtractorException(
+ String.format(
+ "Failed to convert table %s schema", metaClient.getTableConfig().getTableName()),
+ e);
+ }
+ return canonicalSchema;
+ }
+
private List getRecordKeyFields(
HoodieTableMetaClient metaClient, InternalSchema canonicalSchema) {
Option recordKeyFieldNames = metaClient.getTableConfig().getRecordKeyFields();
@@ -101,4 +181,9 @@ private List getRecordKeyFields(
.map(name -> SchemaFieldFinder.getInstance().findFieldByPath(canonicalSchema, name))
.collect(Collectors.toList());
}
+
+ @SneakyThrows
+ private String generateTableOperationId(HoodieInstant completedInstant) {
+ return MAPPER.writeValueAsString(InstantDTO.fromInstant(completedInstant));
+ }
}
diff --git a/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergConversionTarget.java b/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergConversionTarget.java
index bac7b5102..65a8c1218 100644
--- a/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergConversionTarget.java
+++ b/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergConversionTarget.java
@@ -32,6 +32,7 @@
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
+import org.apache.iceberg.ExpireSnapshots;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
@@ -296,9 +297,7 @@ public void completeSync() {
.cleanExpiredFiles(true)
.commit();
transaction.commitTransaction();
- transaction = null;
- internalTableState = null;
- tableSyncMetadata = null;
+ resetTransactionState();
}
private void safeDelete(String file) {
@@ -347,6 +346,45 @@ public Optional getTargetCommitIdentifier(String sourceIdentifier) {
return Optional.empty();
}
+ /**
+ * Expires the given snapshots and ends the sync. Requires {@link #beginSync} to have run. Passing
+ * an empty list is a no-op rather than an empty metadata commit, since callers driven by Hudi
+ * archival reach this on every round.
+ *
+ * @param snapshotIds snapshots to expire
+ */
+ public void expireSnapshotIds(List snapshotIds) {
+ if (snapshotIds.isEmpty()) {
+ // Nothing to expire, so end the sync without writing a metadata version that changes nothing.
+ resetTransactionState();
+ return;
+ }
+ ExpireSnapshots expireSnapshots = transaction.expireSnapshots().deleteWith(this::safeDelete);
+ for (Long snapshotId : snapshotIds) {
+ expireSnapshots.expireSnapshotId(snapshotId);
+ }
+ expireSnapshots.commit();
+ transaction.commitTransaction();
+ resetTransactionState();
+ }
+
+ /**
+ * Makes the given snapshot current and ends the sync. Requires {@link #beginSync} to have run.
+ *
+ * @param snapshotId the snapshot to roll back to, which must be an ancestor of the current one
+ */
+ public void rollbackToSnapshotId(long snapshotId) {
+ table.manageSnapshots().rollbackTo(snapshotId).commit();
+ transaction.commitTransaction();
+ resetTransactionState();
+ }
+
+ private void resetTransactionState() {
+ transaction = null;
+ internalTableState = null;
+ tableSyncMetadata = null;
+ }
+
private void rollbackCorruptCommits() {
if (table == null) {
// there is no existing table so exit early
diff --git a/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergTableManager.java b/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergTableManager.java
index 19f162a63..177083406 100644
--- a/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergTableManager.java
+++ b/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergTableManager.java
@@ -43,21 +43,21 @@
@AllArgsConstructor(staticName = "of")
@Log4j2
-class IcebergTableManager {
+public class IcebergTableManager {
private static final Map CATALOG_CACHE = new ConcurrentHashMap<>();
private final Configuration hadoopConfiguration;
@Getter(lazy = true, value = lombok.AccessLevel.PRIVATE)
private final HadoopTables hadoopTables = new HadoopTables(hadoopConfiguration);
- Table getTable(
+ public Table getTable(
IcebergCatalogConfig catalogConfig, TableIdentifier tableIdentifier, String basePath) {
return getCatalog(catalogConfig)
.map(catalog -> catalog.loadTable(tableIdentifier))
.orElseGet(() -> getHadoopTables().load(basePath));
}
- boolean tableExists(
+ public boolean tableExists(
IcebergCatalogConfig catalogConfig, TableIdentifier tableIdentifier, String basePath) {
return getCatalog(catalogConfig)
.map(catalog -> catalog.tableExists(tableIdentifier))
diff --git a/xtable-core/src/test/java/org/apache/xtable/TestAbstractHudiTable.java b/xtable-core/src/test/java/org/apache/xtable/TestAbstractHudiTable.java
index a5909a04c..cefc8d0b1 100644
--- a/xtable-core/src/test/java/org/apache/xtable/TestAbstractHudiTable.java
+++ b/xtable-core/src/test/java/org/apache/xtable/TestAbstractHudiTable.java
@@ -81,6 +81,7 @@
import org.apache.hudi.common.model.HoodieTimelineTimeZone;
import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload;
import org.apache.hudi.common.model.WriteConcurrencyMode;
+import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.common.table.HoodieTableMetaClient;
import org.apache.hudi.common.table.HoodieTableVersion;
import org.apache.hudi.common.table.marker.MarkerType;
@@ -153,6 +154,7 @@ public abstract class TestAbstractHudiTable
this.typedProperties = new TypedProperties();
typedProperties.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), RECORD_KEY_FIELD_NAME);
typedProperties.put(HoodieMetadataConfig.ENABLE.key(), "true");
+ typedProperties.putAll(tableFormatOverrides());
if (partitionConfig == null) {
this.keyGenerator = new NonpartitionedKeyGenerator(typedProperties);
this.partitionFieldNames = Collections.emptyList();
@@ -445,10 +447,15 @@ protected HoodieWriteConfig generateWriteConfig(Schema schema, TypedProperties k
// stats when the schema does not contain those types.
// https://github.com/apache/incubator-xtable/issues/773
// boolean columnStatsSupported = !schemaContainsArrayOrMap(schema);
+ // A table format that supplies its own metadata, such as a pluggable format, can turn the
+ // Hudi metadata table off through the properties.
+ boolean metadataTableEnabled =
+ Boolean.parseBoolean(
+ keyGenProperties.getProperty(HoodieMetadataConfig.ENABLE.key(), "true"));
HoodieMetadataConfig metadataConfig =
HoodieMetadataConfig.newBuilder()
- .enable(true)
- .withMetadataIndexColumnStats(true)
+ .enable(metadataTableEnabled)
+ .withMetadataIndexColumnStats(metadataTableEnabled)
.withColumnStatsIndexForColumns(getColumnsFromSchema(schema))
.build();
Properties lockProperties = new Properties();
@@ -460,7 +467,7 @@ protected HoodieWriteConfig generateWriteConfig(Schema schema, TypedProperties k
// Pin writes to table version 6 and disable auto-upgrade so the write client does not
// upgrade the test table to version 9. Table version 9 support will be added in a
// follow-up PR.
- .withWriteTableVersion(HoodieTableVersion.SIX.versionCode())
+ .withWriteTableVersion(tableVersion(keyGenProperties).versionCode())
.withAutoUpgradeVersion(false)
.withProperties(keyGenProperties)
.withPath(this.basePath)
@@ -611,6 +618,57 @@ protected HoodieTableMetaClient getMetaClient(
HoodieTableType hoodieTableType,
Configuration conf,
boolean populateMetaFields) {
+ return getMetaClient(
+ keyGenProperties, hoodieTableType, conf, populateMetaFields, new Properties());
+ }
+
+ /**
+ * Table-level properties selecting the pluggable table format named by the {@code
+ * hoodie.table.format} system property, empty when it is unset. A module whose tests all run
+ * against one format sets the property once for the JVM rather than threading properties through
+ * every table constructor.
+ */
+ protected static Properties tableFormatOverrides() {
+ return tableFormatOverrides(System.getProperty(HoodieTableConfig.TABLE_FORMAT.key()));
+ }
+
+ /** @param tableFormat the requested pluggable format, or null for Hudi's native format. */
+ static Properties tableFormatOverrides(String tableFormat) {
+ Properties overrides = new Properties();
+ if (tableFormat != null) {
+ overrides.put(HoodieTableConfig.TABLE_FORMAT.key(), tableFormat);
+ // A pluggable format reconstructs the timeline from its own metadata, which needs the v2
+ // timeline layout, and supplies the file listing that the Hudi metadata table would.
+ overrides.put(
+ HoodieTableConfig.VERSION.key(), String.valueOf(HoodieTableVersion.EIGHT.versionCode()));
+ // FileSystemBackedTableMetadata, which IcebergBackedTableMetadata extends, throws on every
+ // index lookup, so leaving the metadata table on fails with "Unsupported operation:
+ // getColumnsStats".
+ overrides.put(HoodieMetadataConfig.ENABLE.key(), "false");
+ }
+ return overrides;
+ }
+
+ private static HoodieTableVersion tableVersion(Properties tableProperties) {
+ String configured = tableProperties.getProperty(HoodieTableConfig.VERSION.key());
+ return configured == null
+ ? HoodieTableVersion.SIX
+ : HoodieTableVersion.fromVersionCode(Integer.parseInt(configured));
+ }
+
+ /**
+ * @param tableProperties table-level properties to persist into {@code hoodie.properties}, for
+ * example {@code hoodie.table.format} or {@code hoodie.table.version}. {@code
+ * builder.set(Map)} does not persist these, so they are applied through {@code
+ * fromProperties} instead.
+ */
+ @SneakyThrows
+ protected HoodieTableMetaClient getMetaClient(
+ TypedProperties keyGenProperties,
+ HoodieTableType hoodieTableType,
+ Configuration conf,
+ boolean populateMetaFields,
+ Properties tableProperties) {
LocalFileSystem fs = (LocalFileSystem) HadoopFSUtils.getFs(basePath, conf);
// Enforce checksum such that fs.open() is consistent to DFS
fs.setVerifyChecksum(true);
@@ -625,13 +683,17 @@ protected HoodieTableMetaClient getMetaClient(
}
@SuppressWarnings("unchecked")
Map keyGenPropsMap = (Map) keyGenProperties;
+ Properties effectiveTableProperties = tableFormatOverrides();
+ effectiveTableProperties.putAll(tableProperties);
return HoodieTableMetaClient.newTableBuilder()
.set(keyGenPropsMap)
+ .fromProperties(effectiveTableProperties)
.setTableName(tableName)
.setTableType(hoodieTableType)
// Pin test tables to table version 6 to match the conversion target. Table version 9
- // support will be added in a follow-up PR.
- .setTableVersion(HoodieTableVersion.SIX)
+ // support will be added in a follow-up PR. A test may override this through
+ // tableProperties, for example a pluggable table format that needs the v2 timeline.
+ .setTableVersion(tableVersion(effectiveTableProperties))
.setKeyGeneratorClassProp(keyGenerator.getClass().getCanonicalName())
.setPartitionFields(String.join(",", partitionFieldNames))
.setRecordKeyFields(RECORD_KEY_FIELD_NAME)
diff --git a/xtable-core/src/test/java/org/apache/xtable/TestHudiTableFormatOverrides.java b/xtable-core/src/test/java/org/apache/xtable/TestHudiTableFormatOverrides.java
new file mode 100644
index 000000000..fda966638
--- /dev/null
+++ b/xtable-core/src/test/java/org/apache/xtable/TestHudiTableFormatOverrides.java
@@ -0,0 +1,56 @@
+/*
+ * 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.xtable;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableVersion;
+
+/**
+ * Guards the table-format override that {@link TestAbstractHudiTable} applies. Every Hudi test in
+ * the repository shares that harness, so the override has to stay inert unless a module explicitly
+ * asks for a pluggable format.
+ */
+class TestHudiTableFormatOverrides {
+
+ @Test
+ void emptyForTheNativeFormat() {
+ assertTrue(
+ TestAbstractHudiTable.tableFormatOverrides(null).isEmpty(),
+ "a module that does not ask for a pluggable format must get no overrides");
+ }
+
+ @Test
+ void suppliesFormatVersionAndMetadataSettingForAPluggableFormat() {
+ Properties overrides = TestAbstractHudiTable.tableFormatOverrides("ICEBERG");
+ assertEquals("ICEBERG", overrides.getProperty(HoodieTableConfig.TABLE_FORMAT.key()));
+ assertEquals(
+ String.valueOf(HoodieTableVersion.EIGHT.versionCode()),
+ overrides.getProperty(HoodieTableConfig.VERSION.key()),
+ "a pluggable format needs the v2 timeline layout, which table version 6 does not have");
+ assertEquals("false", overrides.getProperty(HoodieMetadataConfig.ENABLE.key()));
+ }
+}
diff --git a/xtable-core/src/test/java/org/apache/xtable/TestJavaHudiTable.java b/xtable-core/src/test/java/org/apache/xtable/TestJavaHudiTable.java
index 499ac08f8..10f1c09a8 100644
--- a/xtable-core/src/test/java/org/apache/xtable/TestJavaHudiTable.java
+++ b/xtable-core/src/test/java/org/apache/xtable/TestJavaHudiTable.java
@@ -27,6 +27,7 @@
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.List;
+import java.util.Properties;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -86,13 +87,35 @@ public class TestJavaHudiTable extends TestAbstractHudiTable {
public static TestJavaHudiTable forStandardSchema(
String tableName, Path tempDir, String partitionConfig, HoodieTableType tableType) {
return new TestJavaHudiTable(
- tableName, BASIC_SCHEMA, tempDir, partitionConfig, tableType, null, false);
+ tableName,
+ BASIC_SCHEMA,
+ tempDir,
+ partitionConfig,
+ tableType,
+ null,
+ false,
+ new Properties());
+ }
+
+ /**
+ * Same as {@link #forStandardSchema(String, Path, String, HoodieTableType)}, but persists the
+ * given table-level properties into {@code hoodie.properties}. Use this to set {@code
+ * hoodie.table.format} so that a pluggable table format is active for the table.
+ */
+ public static TestJavaHudiTable forStandardSchema(
+ String tableName,
+ Path tempDir,
+ String partitionConfig,
+ HoodieTableType tableType,
+ Properties tableProperties) {
+ return new TestJavaHudiTable(
+ tableName, BASIC_SCHEMA, tempDir, partitionConfig, tableType, null, false, tableProperties);
}
public static TestJavaHudiTable forStandardSchemaWithFieldIds(
String tableName, Path tempDir, String partitionConfig, HoodieTableType tableType) {
return new TestJavaHudiTable(
- tableName, BASIC_SCHEMA, tempDir, partitionConfig, tableType, null, true);
+ tableName, BASIC_SCHEMA, tempDir, partitionConfig, tableType, null, true, new Properties());
}
public static TestJavaHudiTable forStandardSchema(
@@ -102,7 +125,14 @@ public static TestJavaHudiTable forStandardSchema(
HoodieTableType tableType,
HoodieArchivalConfig archivalConfig) {
return new TestJavaHudiTable(
- tableName, BASIC_SCHEMA, tempDir, partitionConfig, tableType, archivalConfig, false);
+ tableName,
+ BASIC_SCHEMA,
+ tempDir,
+ partitionConfig,
+ tableType,
+ archivalConfig,
+ false,
+ new Properties());
}
/**
@@ -129,7 +159,8 @@ public static TestJavaHudiTable withAdditionalColumns(
partitionConfig,
tableType,
null,
- false);
+ false,
+ new Properties());
}
public static TestJavaHudiTable withAdditionalColumnsAndFieldIds(
@@ -141,7 +172,8 @@ public static TestJavaHudiTable withAdditionalColumnsAndFieldIds(
partitionConfig,
tableType,
null,
- true);
+ true,
+ new Properties());
}
public static TestJavaHudiTable withAdditionalTopLevelField(
@@ -157,7 +189,8 @@ public static TestJavaHudiTable withAdditionalTopLevelField(
partitionConfig,
tableType,
null,
- false);
+ false,
+ new Properties());
}
public static TestJavaHudiTable withSchema(
@@ -167,7 +200,7 @@ public static TestJavaHudiTable withSchema(
HoodieTableType tableType,
Schema schema) {
return new TestJavaHudiTable(
- tableName, schema, tempDir, partitionConfig, tableType, null, false);
+ tableName, schema, tempDir, partitionConfig, tableType, null, false, new Properties());
}
private TestJavaHudiTable(
@@ -177,13 +210,18 @@ private TestJavaHudiTable(
String partitionConfig,
HoodieTableType hoodieTableType,
HoodieArchivalConfig archivalConfig,
- boolean addFieldIds) {
+ boolean addFieldIds,
+ Properties tableProperties) {
super(name, schema, tempDir, partitionConfig);
this.conf = new Configuration();
this.conf.set("parquet.avro.write-old-list-structure", "false");
this.addFieldIds = addFieldIds;
+ // The caller's properties also override the defaults this class puts in the write config, so a
+ // test can turn off features that its table format does not support, such as the metadata
+ // table.
+ tableProperties.forEach((key, value) -> typedProperties.put(key, value));
try {
- this.metaClient = initMetaClient(hoodieTableType, typedProperties);
+ this.metaClient = initMetaClient(hoodieTableType, typedProperties, tableProperties);
} catch (IOException ex) {
throw new UncheckedIOException("Unable to initialize metaclient for TestJavaHudiTable", ex);
}
@@ -330,8 +368,9 @@ private List> copyRecords(
}
private HoodieTableMetaClient initMetaClient(
- HoodieTableType hoodieTableType, TypedProperties keyGenProperties) throws IOException {
- return getMetaClient(keyGenProperties, hoodieTableType, conf, !addFieldIds);
+ HoodieTableType hoodieTableType, TypedProperties keyGenProperties, Properties tableProperties)
+ throws IOException {
+ return getMetaClient(keyGenProperties, hoodieTableType, conf, !addFieldIds, tableProperties);
}
private HoodieJavaWriteClient initJavaWriteClient(
diff --git a/xtable-hudi-support/pom.xml b/xtable-hudi-support/pom.xml
index fb5ec9258..1ab6ea113 100644
--- a/xtable-hudi-support/pom.xml
+++ b/xtable-hudi-support/pom.xml
@@ -32,5 +32,6 @@
xtable-hudi-support-utils
xtable-hudi-support-extensions
+ xtable-iceberg-pluggable-tf
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/pom.xml b/xtable-hudi-support/xtable-iceberg-pluggable-tf/pom.xml
new file mode 100644
index 000000000..8431d9cbb
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/pom.xml
@@ -0,0 +1,209 @@
+
+
+
+ 4.0.0
+
+
+ org.apache.xtable
+ xtable-hudi-support
+ 0.5.0-SNAPSHOT
+
+
+ xtable-iceberg-pluggable-tf_${scala.binary.version}
+ XTable Project Iceberg Pluggable Table Format
+
+
+
+
+ org.apache.xtable
+ xtable-core_${scala.binary.version}
+ ${project.version}
+
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+ org.apache.hudi
+ hudi-client-common
+ provided
+
+
+ org.apache.hudi
+ hudi-sync-common
+ provided
+
+
+ org.apache.hadoop
+ hadoop-common
+ provided
+
+
+
+
+ org.apache.avro
+ avro
+ provided
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ ${jackson.version}
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+ provided
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+
+
+ org.apache.iceberg
+ iceberg-core
+
+
+ io.airlift
+ aircompressor
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+
+
+
+
+ org.apache.hudi
+ hudi-common
+ provided
+
+
+ org.openjdk.jol
+ jol-core
+ test
+
+
+
+
+ org.apache.hudi
+ hudi-spark${spark.version.prefix}-bundle_${scala.binary.version}
+ test
+
+
+ org.apache.hudi
+ hudi-java-client
+ test
+
+
+ com.esotericsoftware
+ kryo
+ test
+
+
+ org.apache.spark
+ spark-core_${scala.binary.version}
+ test
+
+
+ org.apache.xtable
+ xtable-core_${scala.binary.version}
+ ${project.version}
+ tests
+ test-jar
+ test
+
+
+ org.apache.iceberg
+ iceberg-spark-runtime-${spark.version.prefix}_${scala.binary.version}
+ test
+
+
+
+ io.delta
+ delta-core_${scala.binary.version}
+ test
+
+
+ org.apache.spark
+ spark-sql_${scala.binary.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
+
+ org.apache.logging.log4j
+ log4j-core
+ test
+
+
+ org.apache.logging.log4j
+ log4j-slf4j2-impl
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
+
+ ICEBERG
+
+
+
+
+
+
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/IcebergTableFormat.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/IcebergTableFormat.java
new file mode 100644
index 000000000..2cfd7c340
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/IcebergTableFormat.java
@@ -0,0 +1,232 @@
+/*
+ * 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.xtable;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import org.apache.hadoop.conf.Configuration;
+
+import org.apache.hudi.avro.model.HoodieCleanMetadata;
+import org.apache.hudi.common.HoodieTableFormat;
+import org.apache.hudi.common.config.HoodieConfig;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.TimelineFactory;
+import org.apache.hudi.common.table.view.FileSystemViewManager;
+import org.apache.hudi.metadata.TableMetadataFactory;
+
+import org.apache.xtable.conversion.ConversionTargetFactory;
+import org.apache.xtable.conversion.TargetTable;
+import org.apache.xtable.exception.UpdateException;
+import org.apache.xtable.hudi.HudiDataFileExtractor;
+import org.apache.xtable.hudi.HudiFileStatsExtractor;
+import org.apache.xtable.hudi.HudiIncrementalTableChangeExtractor;
+import org.apache.xtable.hudi.HudiSchemaExtractor;
+import org.apache.xtable.hudi.HudiSourceConfig;
+import org.apache.xtable.hudi.HudiTableExtractor;
+import org.apache.xtable.hudi.PathBasedPartitionSpecExtractor;
+import org.apache.xtable.hudi.PathBasedPartitionValuesExtractor;
+import org.apache.xtable.iceberg.IcebergConversionTarget;
+import org.apache.xtable.metadata.IcebergMetadataFactory;
+import org.apache.xtable.model.IncrementalTableChanges;
+import org.apache.xtable.model.InternalTable;
+import org.apache.xtable.model.metadata.TableSyncMetadata;
+import org.apache.xtable.spi.sync.TableFormatSync;
+import org.apache.xtable.timeline.IcebergRollbackExecutor;
+import org.apache.xtable.timeline.IcebergTimelineArchiver;
+import org.apache.xtable.timeline.IcebergTimelineFactory;
+
+public class IcebergTableFormat implements HoodieTableFormat {
+ private transient TableFormatSync tableFormatSync;
+
+ public IcebergTableFormat() {}
+
+ @Override
+ public void init(Properties properties) {
+ this.tableFormatSync = TableFormatSync.getInstance();
+ }
+
+ @Override
+ public String getName() {
+ return org.apache.xtable.model.storage.TableFormat.ICEBERG;
+ }
+
+ @Override
+ public void commit(
+ HoodieCommitMetadata commitMetadata,
+ HoodieInstant completedInstant,
+ HoodieEngineContext engineContext,
+ HoodieTableMetaClient metaClient,
+ FileSystemViewManager viewManager) {
+ HudiIncrementalTableChangeExtractor hudiTableExtractor =
+ getHudiTableExtractor(metaClient, viewManager);
+ completeInstant(
+ metaClient, hudiTableExtractor.extractTableChanges(commitMetadata, completedInstant));
+ }
+
+ @Override
+ public void clean(
+ HoodieCleanMetadata cleanMetadata,
+ HoodieInstant completedInstant,
+ HoodieEngineContext engineContext,
+ HoodieTableMetaClient metaClient,
+ FileSystemViewManager viewManager) {
+ HudiIncrementalTableChangeExtractor hudiTableExtractor =
+ getHudiTableExtractor(metaClient, viewManager);
+ completeInstant(metaClient, hudiTableExtractor.extractTableChanges(completedInstant));
+ }
+
+ @Override
+ public void archive(
+ Supplier> archivedInstants,
+ HoodieEngineContext engineContext,
+ HoodieTableMetaClient metaClient,
+ FileSystemViewManager viewManager) {
+ HudiIncrementalTableChangeExtractor hudiTableExtractor =
+ getHudiTableExtractor(metaClient, viewManager);
+ InternalTable internalTable =
+ hudiTableExtractor
+ .getTableExtractor()
+ .table(
+ metaClient,
+ metaClient.getActiveTimeline().filterCompletedInstants().lastInstant().get());
+ archiveInstants(metaClient, internalTable, archivedInstants.get());
+ }
+
+ @Override
+ public void rollback(
+ HoodieInstant completedInstant,
+ HoodieEngineContext engineContext,
+ HoodieTableMetaClient metaClient,
+ FileSystemViewManager viewManager) {
+ HudiIncrementalTableChangeExtractor hudiTableExtractor =
+ getHudiTableExtractor(metaClient, viewManager);
+ InternalTable internalTable =
+ hudiTableExtractor
+ .getTableExtractor()
+ .table(
+ metaClient,
+ metaClient.getActiveTimeline().filterCompletedInstants().lastInstant().get());
+ IcebergRollbackExecutor rollbackExecutor =
+ new IcebergRollbackExecutor(metaClient, getIcebergConversionTarget(metaClient));
+ rollbackExecutor.rollbackSnapshot(internalTable, completedInstant);
+ }
+
+ @Override
+ public void completedRollback(
+ HoodieInstant rollbackInstant,
+ HoodieEngineContext engineContext,
+ HoodieTableMetaClient metaClient,
+ FileSystemViewManager viewManager) {
+ metaClient.reloadActiveTimeline();
+ HudiIncrementalTableChangeExtractor hudiTableExtractor =
+ getHudiTableExtractor(metaClient, viewManager);
+ completeInstant(metaClient, hudiTableExtractor.extractTableChanges(rollbackInstant));
+ }
+
+ @Override
+ public void savepoint(
+ HoodieInstant instant,
+ HoodieEngineContext engineContext,
+ HoodieTableMetaClient metaClient,
+ FileSystemViewManager viewManager) {
+ HudiIncrementalTableChangeExtractor hudiTableExtractor =
+ getHudiTableExtractor(metaClient, viewManager);
+ completeInstant(metaClient, hudiTableExtractor.extractTableChanges(instant));
+ }
+
+ @Override
+ public TimelineFactory getTimelineFactory() {
+ return new IcebergTimelineFactory(new HoodieConfig());
+ }
+
+ @Override
+ public TableMetadataFactory getMetadataFactory() {
+ return IcebergMetadataFactory.getInstance();
+ }
+
+ private void completeInstant(HoodieTableMetaClient metaClient, IncrementalTableChanges changes) {
+ IcebergConversionTarget target = getIcebergConversionTarget(metaClient);
+ TableSyncMetadata tableSyncMetadata =
+ target
+ .getTableMetadata()
+ .orElse(TableSyncMetadata.of(Instant.MIN, Collections.emptyList()));
+ try {
+ tableFormatSync.syncChanges(Collections.singletonMap(target, tableSyncMetadata), changes);
+ } catch (Exception e) {
+ throw new UpdateException("Failed to update iceberg metadata", e);
+ }
+ }
+
+ private void archiveInstants(
+ HoodieTableMetaClient metaClient,
+ InternalTable internalTable,
+ List archivedInstants) {
+ IcebergConversionTarget target = getIcebergConversionTarget(metaClient);
+ IcebergTimelineArchiver timelineArchiver = new IcebergTimelineArchiver(metaClient, target);
+ timelineArchiver.archiveInstants(internalTable, archivedInstants);
+ }
+
+ private HudiIncrementalTableChangeExtractor getHudiTableExtractor(
+ HoodieTableMetaClient metaClient, FileSystemViewManager viewManager) {
+ String partitionSpec =
+ metaClient
+ .getTableConfig()
+ .getPartitionFields()
+ .map(
+ partitionPaths ->
+ Arrays.stream(partitionPaths)
+ .map(p -> String.format("%s:VALUE", p))
+ .collect(Collectors.joining(",")))
+ .orElse(null);
+ final PathBasedPartitionSpecExtractor sourcePartitionSpecExtractor =
+ HudiSourceConfig.fromPartitionFieldSpecConfig(partitionSpec)
+ .loadSourcePartitionSpecExtractor();
+ return new HudiIncrementalTableChangeExtractor(
+ metaClient,
+ new HudiTableExtractor(new HudiSchemaExtractor(), sourcePartitionSpecExtractor),
+ new HudiDataFileExtractor(
+ metaClient,
+ new PathBasedPartitionValuesExtractor(
+ sourcePartitionSpecExtractor.getPathToPartitionFieldFormat()),
+ new HudiFileStatsExtractor(metaClient),
+ viewManager));
+ }
+
+ private IcebergConversionTarget getIcebergConversionTarget(HoodieTableMetaClient metaClient) {
+ // TODO: Add iceberg catalog config through user inputs.
+ TargetTable targetTable =
+ TargetTable.builder()
+ .name(metaClient.getTableConfig().getTableName())
+ .formatName(org.apache.xtable.model.storage.TableFormat.ICEBERG)
+ .basePath(metaClient.getBasePath().toString())
+ .build();
+ return (IcebergConversionTarget)
+ ConversionTargetFactory.getInstance()
+ .createForFormat(targetTable, (Configuration) metaClient.getStorageConf().unwrap());
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/metadata/IcebergBackedTableMetadata.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/metadata/IcebergBackedTableMetadata.java
new file mode 100644
index 000000000..0bbdd7067
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/metadata/IcebergBackedTableMetadata.java
@@ -0,0 +1,41 @@
+/*
+ * 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.xtable.metadata;
+
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.metadata.FileSystemBackedTableMetadata;
+import org.apache.hudi.storage.HoodieStorage;
+
+/**
+ * Serves Hudi's table metadata for a table using the Iceberg table format. It deliberately lists
+ * the file system for now rather than reading Iceberg manifests, which is why the Hudi metadata
+ * table has to stay disabled for such a table: the superclass throws on every index lookup, so an
+ * enabled metadata table fails with "Unsupported operation: getColumnsStats".
+ *
+ * The type exists to be replaced rather than removed. Iceberg manifests already carry the
+ * per-column bounds and file listings this should eventually answer from, which is what RFC-93
+ * means by the plugin's metadata serving the Hudi writer.
+ */
+public class IcebergBackedTableMetadata extends FileSystemBackedTableMetadata {
+
+ public IcebergBackedTableMetadata(
+ HoodieEngineContext engineContext, HoodieStorage storage, String datasetBasePath) {
+ super(engineContext, storage, datasetBasePath);
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/metadata/IcebergMetadataFactory.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/metadata/IcebergMetadataFactory.java
new file mode 100644
index 000000000..6282bacc2
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/metadata/IcebergMetadataFactory.java
@@ -0,0 +1,43 @@
+/*
+ * 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.xtable.metadata;
+
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.engine.HoodieEngineContext;
+import org.apache.hudi.metadata.HoodieTableMetadata;
+import org.apache.hudi.metadata.TableMetadataFactory;
+import org.apache.hudi.storage.HoodieStorage;
+
+public class IcebergMetadataFactory extends TableMetadataFactory {
+ private static final IcebergMetadataFactory INSTANCE = new IcebergMetadataFactory();
+
+ public static IcebergMetadataFactory getInstance() {
+ return INSTANCE;
+ }
+
+ @Override
+ public HoodieTableMetadata create(
+ HoodieEngineContext engineContext,
+ HoodieStorage storage,
+ HoodieMetadataConfig metadataConfig,
+ String datasetBasePath,
+ boolean reuse) {
+ return new IcebergBackedTableMetadata(engineContext, storage, datasetBasePath);
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergActiveTimeline.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergActiveTimeline.java
new file mode 100644
index 000000000..e9b51efd3
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergActiveTimeline.java
@@ -0,0 +1,146 @@
+/*
+ * 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.xtable.timeline;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import lombok.SneakyThrows;
+
+import org.apache.hadoop.conf.Configuration;
+
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.dto.InstantDTO;
+import org.apache.hudi.common.table.timeline.versioning.v2.ActiveTimelineV2;
+import org.apache.hudi.common.table.timeline.versioning.v2.InstantComparatorV2;
+
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.TableIdentifier;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+import org.apache.xtable.iceberg.IcebergTableManager;
+import org.apache.xtable.model.metadata.TableSyncMetadata;
+
+public class IcebergActiveTimeline extends ActiveTimelineV2 {
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ .setSerializationInclusion(JsonInclude.Include.NON_NULL);
+
+ public IcebergActiveTimeline(
+ HoodieTableMetaClient metaClient,
+ Set includedExtensions,
+ boolean applyLayoutFilters) {
+ this.setInstants(getInstantsFromFileSystem(metaClient, includedExtensions, applyLayoutFilters));
+ this.metaClient = metaClient;
+ }
+
+ public IcebergActiveTimeline(HoodieTableMetaClient metaClient) {
+ this(metaClient, Collections.unmodifiableSet(VALID_EXTENSIONS_IN_ACTIVE_TIMELINE), true);
+ }
+
+ public IcebergActiveTimeline(HoodieTableMetaClient metaClient, boolean applyLayoutFilters) {
+ this(
+ metaClient,
+ Collections.unmodifiableSet(VALID_EXTENSIONS_IN_ACTIVE_TIMELINE),
+ applyLayoutFilters);
+ }
+
+ public IcebergActiveTimeline() {}
+
+ @Override
+ public HoodieActiveTimeline reload() {
+ return new IcebergActiveTimeline(metaClient);
+ }
+
+ /**
+ * Requested time alone does not identify an instant: savepointing a commit produces a savepoint
+ * instant at that commit's own requested time, so the action has to be part of the key or the two
+ * collide and one is dropped from the reconstructed timeline.
+ */
+ static String instantKey(HoodieInstant instant) {
+ return instant.requestedTime() + "." + instant.getAction();
+ }
+
+ @SneakyThrows
+ protected List getInstantsFromFileSystem(
+ HoodieTableMetaClient metaClient,
+ Set includedExtensions,
+ boolean applyLayoutFilters) {
+ List instantsFromHoodieTimeline =
+ super.getInstantsFromFileSystem(metaClient, includedExtensions, applyLayoutFilters);
+ IcebergTableManager icebergTableManager =
+ IcebergTableManager.of((Configuration) metaClient.getStorageConf().unwrap());
+ TableIdentifier tableIdentifier =
+ TableIdentifier.of(metaClient.getTableConfig().getTableName());
+ if (!icebergTableManager.tableExists(
+ null, tableIdentifier, metaClient.getBasePath().toString())) {
+ return Collections.emptyList();
+ }
+ Table icebergTable =
+ icebergTableManager.getTable(null, tableIdentifier, metaClient.getBasePath().toString());
+ Map instantsFromIceberg = new HashMap<>();
+ for (Snapshot snapshot : icebergTable.snapshots()) {
+ TableSyncMetadata syncMetadata =
+ TableSyncMetadata.fromJson(snapshot.summary().get(TableSyncMetadata.XTABLE_METADATA))
+ .get();
+ HoodieInstant hoodieInstant =
+ InstantDTO.toInstant(
+ MAPPER.readValue(syncMetadata.getLatestTableOperationIdentifier(), InstantDTO.class),
+ metaClient.getInstantGenerator());
+ instantsFromIceberg.put(instantKey(hoodieInstant), hoodieInstant);
+ }
+ List inflightInstantsInIceberg =
+ instantsFromHoodieTimeline.stream()
+ .filter(hoodieInstant -> !instantsFromIceberg.containsKey(instantKey(hoodieInstant)))
+ .map(
+ instant -> {
+ if (instant.isCompleted()) {
+ return new HoodieInstant(
+ HoodieInstant.State.INFLIGHT,
+ instant.getAction(),
+ instant.requestedTime(),
+ instant.getCompletionTime(),
+ InstantComparatorV2.REQUESTED_TIME_BASED_COMPARATOR);
+ }
+ return instant;
+ })
+ .collect(Collectors.toList());
+ List completedInstantsInIceberg =
+ instantsFromIceberg.values().stream()
+ .filter(instantsFromHoodieTimeline::contains)
+ .collect(Collectors.toList());
+ return Stream.concat(completedInstantsInIceberg.stream(), inflightInstantsInIceberg.stream())
+ .sorted(InstantComparatorV2.REQUESTED_TIME_BASED_COMPARATOR)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergRollbackExecutor.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergRollbackExecutor.java
new file mode 100644
index 000000000..2e1b286b3
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergRollbackExecutor.java
@@ -0,0 +1,101 @@
+/*
+ * 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.xtable.timeline;
+
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.InstantComparison;
+import org.apache.hudi.common.table.timeline.dto.InstantDTO;
+
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.TableIdentifier;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+import org.apache.xtable.iceberg.IcebergConversionTarget;
+import org.apache.xtable.iceberg.IcebergTableManager;
+import org.apache.xtable.model.InternalTable;
+import org.apache.xtable.model.metadata.TableSyncMetadata;
+
+@Log4j2
+public class IcebergRollbackExecutor {
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ .setSerializationInclusion(JsonInclude.Include.NON_NULL);
+
+ private final HoodieTableMetaClient metaClient;
+ private final IcebergConversionTarget target;
+ private final IcebergTableManager tableManager;
+
+ public IcebergRollbackExecutor(HoodieTableMetaClient metaClient, IcebergConversionTarget target) {
+ this.metaClient = metaClient;
+ this.target = target;
+ this.tableManager =
+ IcebergTableManager.of(
+ (org.apache.hadoop.conf.Configuration) metaClient.getStorageConf().unwrap());
+ }
+
+ @SneakyThrows
+ public void rollbackSnapshot(InternalTable internalTable, HoodieInstant instantToRollback) {
+ TableIdentifier tableIdentifier =
+ TableIdentifier.of(metaClient.getTableConfig().getTableName());
+ if (tableManager.tableExists(null, tableIdentifier, metaClient.getBasePath().toString())) {
+ Table table =
+ tableManager.getTable(null, tableIdentifier, metaClient.getBasePath().toString());
+ TableSyncMetadata syncMetadata =
+ TableSyncMetadata.fromJson(
+ table.currentSnapshot().summary().get(TableSyncMetadata.XTABLE_METADATA))
+ .get();
+ HoodieInstant latestHoodieInstantInIceberg =
+ InstantDTO.toInstant(
+ MAPPER.readValue(syncMetadata.getLatestTableOperationIdentifier(), InstantDTO.class),
+ metaClient.getInstantGenerator());
+ if (latestHoodieInstantInIceberg.equals(instantToRollback)) {
+ // The instant to rollback is committed in iceberg, so rollback to previous snapshot.
+ // NOTE: This is equivalent to hudi restore and should be performed by killing all active
+ // writers.
+ target.beginSync(internalTable);
+ target.rollbackToSnapshotId(table.currentSnapshot().snapshotId());
+ } else if (InstantComparison.compareTimestamps(
+ latestHoodieInstantInIceberg.getCompletionTime(),
+ InstantComparison.LESSER_THAN,
+ instantToRollback.getCompletionTime())) {
+ // instantToRollback was never committed in iceberg, so there is nothing to undo.
+ log.info(
+ "Ignoring rollback to instant {} because it is not committed in Iceberg. Latest committed instant in Iceberg is {}",
+ instantToRollback,
+ latestHoodieInstantInIceberg);
+ } else {
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot rollback to instant '%s' because it is older than the latest committed Hudi instant in Iceberg '%s'. "
+ + "Rolling back would create an inconsistent state.",
+ instantToRollback, latestHoodieInstantInIceberg));
+ }
+ }
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergTimelineArchiver.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergTimelineArchiver.java
new file mode 100644
index 000000000..19db42547
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergTimelineArchiver.java
@@ -0,0 +1,106 @@
+/*
+ * 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.xtable.timeline;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+
+import org.apache.hadoop.conf.Configuration;
+
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.table.timeline.dto.InstantDTO;
+
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.TableIdentifier;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+import org.apache.xtable.iceberg.IcebergConversionTarget;
+import org.apache.xtable.iceberg.IcebergTableManager;
+import org.apache.xtable.model.InternalTable;
+import org.apache.xtable.model.metadata.TableSyncMetadata;
+
+@Log4j2
+public class IcebergTimelineArchiver {
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper()
+ .registerModule(new JavaTimeModule())
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ .setSerializationInclusion(JsonInclude.Include.NON_NULL);
+
+ private final HoodieTableMetaClient metaClient;
+ private final IcebergConversionTarget target;
+ private final IcebergTableManager tableManager;
+
+ public IcebergTimelineArchiver(HoodieTableMetaClient metaClient, IcebergConversionTarget target) {
+ this.metaClient = metaClient;
+ this.target = target;
+ this.tableManager =
+ IcebergTableManager.of((Configuration) metaClient.getStorageConf().unwrap());
+ }
+
+ @SneakyThrows
+ public void archiveInstants(InternalTable internalTable, List archivedInstants) {
+ TableIdentifier tableIdentifier =
+ TableIdentifier.of(metaClient.getTableConfig().getTableName());
+ if (tableManager.tableExists(null, tableIdentifier, metaClient.getBasePath().toString())) {
+ Table table =
+ tableManager.getTable(null, tableIdentifier, metaClient.getBasePath().toString());
+ List expireSnapshots = new ArrayList<>();
+ // Iceberg does not document an ordering for snapshots(), and stopping at the wrong point
+ // would expire a snapshot a savepoint still needs, so order explicitly.
+ List snapshotsOldestFirst = new ArrayList<>();
+ table.snapshots().forEach(snapshotsOldestFirst::add);
+ // Sequence numbers are all zero on a format-version 1 table, so fall back to commit time.
+ snapshotsOldestFirst.sort(
+ Comparator.comparingLong(Snapshot::sequenceNumber)
+ .thenComparingLong(Snapshot::timestampMillis));
+ for (Snapshot snapshot : snapshotsOldestFirst) {
+ TableSyncMetadata syncMetadata =
+ TableSyncMetadata.fromJson(snapshot.summary().get(TableSyncMetadata.XTABLE_METADATA))
+ .get();
+ HoodieInstant hoodieInstant =
+ InstantDTO.toInstant(
+ MAPPER.readValue(
+ syncMetadata.getLatestTableOperationIdentifier(), InstantDTO.class),
+ metaClient.getInstantGenerator());
+ if (HoodieTimeline.SAVEPOINT_ACTION.equals(hoodieInstant.getAction())) {
+ log.info(
+ "Skipping expiring next set of snapshots because of savepoint {}", hoodieInstant);
+ break;
+ }
+ if (archivedInstants.contains(hoodieInstant)) {
+ expireSnapshots.add(snapshot.snapshotId());
+ }
+ }
+ target.beginSync(internalTable);
+ target.expireSnapshotIds(expireSnapshots);
+ }
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergTimelineFactory.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergTimelineFactory.java
new file mode 100644
index 000000000..4a9793efc
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergTimelineFactory.java
@@ -0,0 +1,92 @@
+/*
+ * 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.xtable.timeline;
+
+import java.util.stream.Stream;
+
+import org.apache.hudi.common.config.HoodieConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.ArchivedTimelineLoader;
+import org.apache.hudi.common.table.timeline.CompletionTimeQueryView;
+import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.timeline.HoodieArchivedTimeline;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieInstantReader;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.table.timeline.TimelineFactory;
+import org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineLoaderV2;
+import org.apache.hudi.common.table.timeline.versioning.v2.ArchivedTimelineV2;
+import org.apache.hudi.common.table.timeline.versioning.v2.BaseTimelineV2;
+import org.apache.hudi.common.table.timeline.versioning.v2.CompletionTimeQueryViewV2;
+
+public class IcebergTimelineFactory extends TimelineFactory {
+
+ public IcebergTimelineFactory(HoodieConfig config) {
+ // To match reflection.
+ }
+
+ @Override
+ public HoodieTimeline createDefaultTimeline(
+ Stream instants, HoodieInstantReader instantReader) {
+ return new BaseTimelineV2(instants, instantReader);
+ }
+
+ @Override
+ public HoodieActiveTimeline createActiveTimeline() {
+ return new IcebergActiveTimeline();
+ }
+
+ @Override
+ public HoodieArchivedTimeline createArchivedTimeline(HoodieTableMetaClient metaClient) {
+ return new ArchivedTimelineV2(metaClient);
+ }
+
+ @Override
+ public HoodieArchivedTimeline createArchivedTimeline(
+ HoodieTableMetaClient metaClient, String startTs) {
+ return new ArchivedTimelineV2(metaClient, startTs);
+ }
+
+ @Override
+ public HoodieArchivedTimeline createArchivedTimeline(
+ HoodieTableMetaClient metaClient, boolean loadInstantDetails) {
+ return new ArchivedTimelineV2(metaClient, loadInstantDetails);
+ }
+
+ @Override
+ public ArchivedTimelineLoader createArchivedTimelineLoader() {
+ return new ArchivedTimelineLoaderV2();
+ }
+
+ @Override
+ public HoodieActiveTimeline createActiveTimeline(HoodieTableMetaClient metaClient) {
+ return new IcebergActiveTimeline(metaClient);
+ }
+
+ @Override
+ public HoodieActiveTimeline createActiveTimeline(
+ HoodieTableMetaClient metaClient, boolean applyLayoutFilter) {
+ return new IcebergActiveTimeline(metaClient, applyLayoutFilter);
+ }
+
+ @Override
+ public CompletionTimeQueryView createCompletionTimeQueryView(HoodieTableMetaClient metaClient) {
+ return new CompletionTimeQueryViewV2(metaClient);
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/resources/META-INF/services/org.apache.hudi.common.HoodieTableFormat b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/resources/META-INF/services/org.apache.hudi.common.HoodieTableFormat
new file mode 100644
index 000000000..168494604
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/resources/META-INF/services/org.apache.hudi.common.HoodieTableFormat
@@ -0,0 +1,18 @@
+##########################################################################
+# 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.
+##########################################################################
+org.apache.xtable.IcebergTableFormat
\ No newline at end of file
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergCleanRemovesFiles.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergCleanRemovesFiles.java
new file mode 100644
index 000000000..cb869cad7
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergCleanRemovesFiles.java
@@ -0,0 +1,98 @@
+/*
+ * 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.xtable;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.hadoop.HadoopTables;
+import org.apache.iceberg.io.CloseableIterable;
+
+/**
+ * A Hudi clean deletes base files from storage. The Iceberg metadata has to stop referencing them,
+ * otherwise a scan resolves paths that no longer exist.
+ */
+class ITIcebergCleanRemovesFiles {
+
+ @TempDir public static Path tempDir;
+
+ @Test
+ void cleanedBaseFilesAreNoLongerReferencedByIceberg() throws IOException {
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ "clean_removes_files", tempDir, null, HoodieTableType.COPY_ON_WRITE)) {
+ // Rewrite the same records so older file slices become cleanable, mirroring the sequence
+ // ITIcebergVariousActions uses before its own clean.
+ String firstCommit = table.startCommit();
+ List> insertsForFirstCommit = table.generateRecords(100);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForFirstCommit, firstCommit, true);
+ table.upsertRecords(insertsForFirstCommit.subList(30, 40), true);
+ String secondCommit = table.startCommit();
+ table.insertRecordsWithCommitAlreadyStarted(table.generateRecords(100), secondCommit, true);
+
+ Set referencedBeforeClean = referencedDataFiles(table.getBasePath());
+ assertFalse(referencedBeforeClean.isEmpty(), "expected Iceberg to reference data files");
+
+ table.clean();
+
+ Set referencedAfterClean = referencedDataFiles(table.getBasePath());
+ assertFalse(referencedAfterClean.isEmpty(), "the clean must not empty the table");
+
+ for (String referenced : referencedAfterClean) {
+ assertTrue(
+ Files.exists(Paths.get(URI.create(referenced).getPath())),
+ "Iceberg still references a path that is no longer on storage: " + referenced);
+ }
+ }
+ }
+
+ private static Set referencedDataFiles(String basePath) throws IOException {
+ Table icebergTable = new HadoopTables(new Configuration()).load(basePath);
+ assertNotNull(icebergTable.currentSnapshot(), "expected an Iceberg snapshot to exist");
+ Set paths = new HashSet<>();
+ try (CloseableIterable tasks = icebergTable.newScan().planFiles()) {
+ for (FileScanTask task : tasks) {
+ DataFile file = task.file();
+ paths.add(file.path().toString());
+ }
+ }
+ return paths;
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergPluggableFormatSync.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergPluggableFormatSync.java
new file mode 100644
index 000000000..7cb55e8f0
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergPluggableFormatSync.java
@@ -0,0 +1,84 @@
+/*
+ * 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.xtable;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.nio.file.Path;
+import java.util.Properties;
+
+import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableVersion;
+
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.hadoop.HadoopTables;
+
+import org.apache.xtable.model.storage.TableFormat;
+
+/**
+ * Proves the end to end contract of the pluggable table format: a Hudi write on a table configured
+ * with {@code hoodie.table.format=ICEBERG} must produce readable Iceberg metadata at the same base
+ * path, with no XTable sync job involved.
+ */
+class ITIcebergPluggableFormatSync {
+
+ @TempDir public static Path tempDir;
+
+ private static Properties icebergFormatProperties() {
+ Properties properties = new Properties();
+ properties.put(HoodieTableConfig.TABLE_FORMAT.key(), TableFormat.ICEBERG);
+ // IcebergTimelineFactory builds on the v2 timeline, so the table must not use the v1 layout
+ // that xtable-core pins its other Hudi test tables to.
+ properties.put(
+ HoodieTableConfig.VERSION.key(), String.valueOf(HoodieTableVersion.EIGHT.versionCode()));
+ // IcebergBackedTableMetadata lists the file system, so it cannot back a Hudi metadata table.
+ properties.put(HoodieMetadataConfig.ENABLE.key(), "false");
+ return properties;
+ }
+
+ @Test
+ void insertProducesIcebergSnapshot() {
+ String tableName = "pluggable_insert";
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, null, HoodieTableType.COPY_ON_WRITE, icebergFormatProperties())) {
+
+ assertEquals(
+ TableFormat.ICEBERG,
+ table.getMetaClient().getTableFormat().getName(),
+ "the table was not created with the Iceberg pluggable format");
+
+ table.insertRecords(100, true);
+
+ Table icebergTable = new HadoopTables(new Configuration()).load(table.getBasePath());
+ Snapshot snapshot = icebergTable.currentSnapshot();
+ assertNotNull(snapshot, "the Hudi commit did not produce an Iceberg snapshot");
+ assertEquals(
+ "100", snapshot.summary().get("total-records"), "Iceberg row count does not match Hudi");
+ }
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergTableFormat.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergTableFormat.java
new file mode 100644
index 000000000..3692f57c6
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergTableFormat.java
@@ -0,0 +1,677 @@
+/*
+ * 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.xtable;
+
+import static org.apache.xtable.GenericTable.getTableName;
+import static org.apache.xtable.hudi.HudiTestUtil.PartitionConfig;
+import static org.apache.xtable.model.storage.TableFormat.HUDI;
+import static org.apache.xtable.model.storage.TableFormat.ICEBERG;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import lombok.Builder;
+import lombok.Value;
+
+import org.apache.spark.SparkConf;
+import org.apache.spark.api.java.JavaSparkContext;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.apache.hudi.client.HoodieReadClient;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.config.HoodieReaderConfig;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.hadoop.HadoopTables;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.apache.xtable.conversion.ConversionSourceProvider;
+import org.apache.xtable.hudi.HudiConversionSourceProvider;
+import org.apache.xtable.hudi.HudiTestUtil;
+import org.apache.xtable.iceberg.IcebergConversionSourceProvider;
+import org.apache.xtable.model.sync.SyncMode;
+
+public class ITIcebergTableFormat {
+ @TempDir public static Path tempDir;
+ private static final DateTimeFormatter DATE_FORMAT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.of("UTC"));
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private static JavaSparkContext jsc;
+ private static SparkSession sparkSession;
+
+ @BeforeAll
+ public static void setupOnce() {
+ SparkConf sparkConf = HudiTestUtil.getSparkConf(tempDir);
+ sparkSession =
+ SparkSession.builder().config(HoodieReadClient.addHoodieSupport(sparkConf)).getOrCreate();
+ sparkSession
+ .sparkContext()
+ .hadoopConfiguration()
+ .set("parquet.avro.write-old-list-structure", "false");
+ jsc = JavaSparkContext.fromSparkContext(sparkSession.sparkContext());
+ }
+
+ @AfterAll
+ public static void teardown() {
+ if (jsc != null) {
+ jsc.close();
+ }
+ if (sparkSession != null) {
+ sparkSession.close();
+ }
+ }
+
+ private static Stream testCasesWithPartitioningAndSyncModes() {
+ return addBasicPartitionCases(testCasesWithSyncModes());
+ }
+
+ private static Stream testCasesWithSyncModes() {
+ return Stream.of(Arguments.of(SyncMode.INCREMENTAL), Arguments.of(SyncMode.FULL));
+ }
+
+ private ConversionSourceProvider> getConversionSourceProvider(String sourceTableFormat) {
+ if (sourceTableFormat.equalsIgnoreCase(HUDI)) {
+ ConversionSourceProvider hudiConversionSourceProvider =
+ new HudiConversionSourceProvider();
+ hudiConversionSourceProvider.init(jsc.hadoopConfiguration());
+ return hudiConversionSourceProvider;
+ } else if (sourceTableFormat.equalsIgnoreCase(ICEBERG)) {
+ ConversionSourceProvider icebergConversionSourceProvider =
+ new IcebergConversionSourceProvider();
+ icebergConversionSourceProvider.init(jsc.hadoopConfiguration());
+ return icebergConversionSourceProvider;
+ } else {
+ throw new IllegalArgumentException("Unsupported source format: " + sourceTableFormat);
+ }
+ }
+
+ private static Stream generateTestParametersForFormatsSyncModesAndPartitioning() {
+ List arguments = new ArrayList<>();
+ for (String sourceTableFormat : Arrays.asList(HUDI)) {
+ for (SyncMode syncMode : SyncMode.values()) {
+ for (boolean isPartitioned : new boolean[] {true, false}) {
+ arguments.add(Arguments.of(sourceTableFormat, syncMode, isPartitioned));
+ }
+ }
+ }
+ return arguments.stream();
+ }
+
+ /*
+ * This test has the following steps at a high level.
+ * 1. Insert few records.
+ * 2. Upsert few records.
+ * 3. Delete few records.
+ * 4. Insert records with new columns.
+ * 5. Insert records in a new partition if table is partitioned.
+ * 6. drop a partition if table is partitioned.
+ * 7. Insert records in the dropped partition again if table is partitioned.
+ */
+ @ParameterizedTest
+ @ValueSource(booleans = {true})
+ public void testVariousOperations(boolean isPartitioned) {
+ String tableName = getTableName();
+ String partitionConfig = null;
+ if (isPartitioned) {
+ partitionConfig = "level:VALUE";
+ }
+ List> insertRecords;
+ try (GenericTable table =
+ GenericTable.getInstance(tableName, tempDir, sparkSession, jsc, HUDI, isPartitioned)) {
+ insertRecords = table.insertRows(100);
+ checkDatasetEquivalence(HUDI, table, Collections.singletonList(ICEBERG), 100);
+
+ // make multiple commits and then sync
+ table.insertRows(100);
+ table.upsertRows(insertRecords.subList(0, 20));
+ checkDatasetEquivalence(HUDI, table, Collections.singletonList(ICEBERG), 200);
+
+ table.deleteRows(insertRecords.subList(30, 50));
+ checkDatasetEquivalence(HUDI, table, Collections.singletonList(ICEBERG), 180);
+ checkDatasetEquivalenceWithFilter(
+ HUDI, table, Collections.singletonList(ICEBERG), table.getFilterQuery());
+ }
+
+ try (GenericTable tableWithUpdatedSchema =
+ GenericTable.getInstanceWithAdditionalColumns(
+ tableName, tempDir, sparkSession, jsc, HUDI, isPartitioned)) {
+ List insertsAfterSchemaUpdate = tableWithUpdatedSchema.insertRows(100);
+ tableWithUpdatedSchema.reload();
+ checkDatasetEquivalence(
+ HUDI, tableWithUpdatedSchema, Collections.singletonList(ICEBERG), 280);
+
+ tableWithUpdatedSchema.deleteRows(insertsAfterSchemaUpdate.subList(60, 90));
+ checkDatasetEquivalence(
+ HUDI, tableWithUpdatedSchema, Collections.singletonList(ICEBERG), 250);
+
+ if (isPartitioned) {
+ // Adds new partition.
+ tableWithUpdatedSchema.insertRecordsForSpecialPartition(50);
+ checkDatasetEquivalence(
+ HUDI, tableWithUpdatedSchema, Collections.singletonList(ICEBERG), 300);
+
+ // Drops partition.
+ tableWithUpdatedSchema.deleteSpecialPartition();
+ checkDatasetEquivalence(
+ HUDI, tableWithUpdatedSchema, Collections.singletonList(ICEBERG), 250);
+
+ // Insert records to the dropped partition again.
+ tableWithUpdatedSchema.insertRecordsForSpecialPartition(50);
+ checkDatasetEquivalence(
+ HUDI, tableWithUpdatedSchema, Collections.singletonList(ICEBERG), 300);
+ }
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("testCasesWithPartitioningAndSyncModes")
+ public void testConcurrentInsertWritesInSource(
+ SyncMode syncMode, PartitionConfig partitionConfig) {
+ String tableName = getTableName();
+ List targetTableFormats = Collections.singletonList(ICEBERG);
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, partitionConfig.getHudiConfig(), HoodieTableType.COPY_ON_WRITE)) {
+ // commit time 1 starts first but ends 2nd.
+ // commit time 2 starts second but ends 1st.
+ List> insertsForCommit1 = table.generateRecords(50);
+ List> insertsForCommit2 = table.generateRecords(50);
+ String commitInstant1 = table.startCommit();
+
+ String commitInstant2 = table.startCommit();
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit2, commitInstant2, true);
+
+ checkDatasetEquivalence(HUDI, table, targetTableFormats, 50);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ checkDatasetEquivalence(HUDI, table, targetTableFormats, 100);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {HUDI})
+ public void testTimeTravelQueries(String sourceTableFormat) throws Exception {
+ String tableName = getTableName();
+ try (GenericTable table =
+ GenericTable.getInstance(tableName, tempDir, sparkSession, jsc, sourceTableFormat, false)) {
+ table.insertRows(50);
+ List targetTableFormats = Collections.singletonList(ICEBERG);
+ Instant instantAfterFirstSync = Instant.now();
+ // sleep before starting the next commit to avoid any rounding issues
+ Thread.sleep(1000);
+
+ table.insertRows(50);
+ Instant instantAfterSecondSync = Instant.now();
+ // sleep before starting the next commit to avoid any rounding issues
+ Thread.sleep(1000);
+
+ table.insertRows(50);
+
+ checkDatasetEquivalence(
+ sourceTableFormat,
+ table,
+ getTimeTravelOption(sourceTableFormat, instantAfterFirstSync),
+ targetTableFormats,
+ targetTableFormats.stream()
+ .collect(
+ Collectors.toMap(
+ Function.identity(),
+ targetTableFormat ->
+ getTimeTravelOption(targetTableFormat, instantAfterFirstSync))),
+ 50);
+ checkDatasetEquivalence(
+ sourceTableFormat,
+ table,
+ getTimeTravelOption(sourceTableFormat, instantAfterSecondSync),
+ targetTableFormats,
+ targetTableFormats.stream()
+ .collect(
+ Collectors.toMap(
+ Function.identity(),
+ targetTableFormat ->
+ getTimeTravelOption(targetTableFormat, instantAfterSecondSync))),
+ 100);
+ }
+ }
+
+ private static Stream provideArgsForPartitionTesting() {
+ String levelFilter = "level = 'INFO'";
+ String severityFilter = "severity = 1";
+ return Stream.of(
+ Arguments.of(
+ buildArgsForPartition(HUDI, ICEBERG, "level:SIMPLE", "level:VALUE", levelFilter)),
+ Arguments.of(
+ buildArgsForPartition(
+ HUDI, ICEBERG, "severity:SIMPLE", "severity:VALUE", severityFilter)));
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideArgsForPartitionTesting")
+ public void testPartitionedData(TableFormatPartitionDataHolder tableFormatPartitionDataHolder) {
+ String tableName = getTableName();
+ String sourceTableFormat = tableFormatPartitionDataHolder.getSourceTableFormat();
+ Optional hudiPartitionConfig = tableFormatPartitionDataHolder.getHudiSourceConfig();
+ String filter = tableFormatPartitionDataHolder.getFilter();
+ GenericTable table;
+ if (hudiPartitionConfig.isPresent()) {
+ table =
+ GenericTable.getInstanceWithCustomPartitionConfig(
+ tableName, tempDir, jsc, sourceTableFormat, hudiPartitionConfig.get());
+ } else {
+ table =
+ GenericTable.getInstance(tableName, tempDir, sparkSession, jsc, sourceTableFormat, true);
+ }
+ try (GenericTable tableToClose = table) {
+ tableToClose.insertRows(100);
+ // Do a second sync to force the test to read back the metadata it wrote earlier
+ tableToClose.insertRows(100);
+ checkDatasetEquivalenceWithFilter(
+ sourceTableFormat, tableToClose, Collections.singletonList(ICEBERG), filter);
+ }
+ }
+
+ @Test
+ public void testSyncWithSingleFormat() {
+ String tableName = getTableName();
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, null, HoodieTableType.COPY_ON_WRITE)) {
+ table.insertRecords(100, true);
+ checkDatasetEquivalence(HUDI, table, Collections.singletonList(ICEBERG), 100);
+
+ table.insertRecords(100, true);
+ checkDatasetEquivalence(HUDI, table, Collections.singletonList(ICEBERG), 200);
+ }
+ }
+
+ @Test
+ public void testOutOfSyncIncrementalSyncs() {
+ String tableName = getTableName();
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, null, HoodieTableType.COPY_ON_WRITE)) {
+ table.insertRecords(50, true);
+ // sync iceberg only
+ checkDatasetEquivalence(HUDI, table, Collections.singletonList(ICEBERG), 50);
+ // insert more records
+ table.insertRecords(50, true);
+ // iceberg will be an incremental sync and delta will need to bootstrap with snapshot sync
+ checkDatasetEquivalence(HUDI, table, Arrays.asList(ICEBERG), 100);
+
+ // insert more records
+ table.insertRecords(50, true);
+ // insert more records
+ table.insertRecords(50, true);
+ // incremental sync for two commits for iceberg only
+ checkDatasetEquivalence(HUDI, table, Collections.singletonList(ICEBERG), 200);
+
+ // insert more records
+ table.insertRecords(50, true);
+ checkDatasetEquivalence(HUDI, table, Arrays.asList(ICEBERG), 250);
+ }
+ }
+
+ @Test
+ public void testMetadataRetention() throws Exception {
+ String tableName = getTableName();
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, null, HoodieTableType.COPY_ON_WRITE)) {
+ table.insertRecords(10, true);
+ // later we will ensure we can still read the source table at this instant to ensure that
+ // neither target cleaned up the underlying parquet files in the table
+ Instant instantAfterFirstCommit = Instant.now();
+ // Ensure gap between commits for time-travel query
+ Thread.sleep(1000);
+ // create 5 total commits to ensure Delta Log cleanup is
+ IntStream.range(0, 4)
+ .forEach(
+ unused -> {
+ table.insertRecords(10, true);
+ });
+ // ensure that hudi rows can still be read and underlying files were not removed
+ List rows =
+ sparkSession
+ .read()
+ .format("hudi")
+ .options(getTimeTravelOption(HUDI, instantAfterFirstCommit))
+ .load(table.getBasePath())
+ .collectAsList();
+ Assertions.assertEquals(10, rows.size());
+ // check snapshots retained in iceberg is under 4
+ Table icebergTable = new HadoopTables().load(table.getBasePath());
+ int snapshotCount =
+ (int) StreamSupport.stream(icebergTable.snapshots().spliterator(), false).count();
+ Assertions.assertEquals(
+ table.getWriteClient().getConfig().getMinCommitsToKeep(), snapshotCount);
+ }
+ }
+
+ private Map getTimeTravelOption(String tableFormat, Instant time) {
+ Map options = new HashMap<>();
+ switch (tableFormat) {
+ case HUDI:
+ options.put("as.of.instant", DATE_FORMAT.format(time));
+ break;
+ case ICEBERG:
+ options.put("as-of-timestamp", String.valueOf(time.toEpochMilli()));
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown table format: " + tableFormat);
+ }
+ return options;
+ }
+
+ private void checkDatasetEquivalenceWithFilter(
+ String sourceFormat,
+ GenericTable, ?> sourceTable,
+ List targetFormats,
+ String filter) {
+ checkDatasetEquivalence(
+ sourceFormat,
+ sourceTable,
+ Collections.emptyMap(),
+ targetFormats,
+ Collections.emptyMap(),
+ null,
+ filter);
+ }
+
+ private void checkDatasetEquivalence(
+ String sourceFormat,
+ GenericTable, ?> sourceTable,
+ List targetFormats,
+ Integer expectedCount) {
+ checkDatasetEquivalence(
+ sourceFormat,
+ sourceTable,
+ Collections.emptyMap(),
+ targetFormats,
+ Collections.emptyMap(),
+ expectedCount,
+ "1 = 1");
+ }
+
+ private void checkDatasetEquivalence(
+ String sourceFormat,
+ GenericTable, ?> sourceTable,
+ Map sourceOptions,
+ List targetFormats,
+ Map> targetOptions,
+ Integer expectedCount) {
+ checkDatasetEquivalence(
+ sourceFormat,
+ sourceTable,
+ sourceOptions,
+ targetFormats,
+ targetOptions,
+ expectedCount,
+ "1 = 1");
+ }
+
+ private void checkDatasetEquivalence(
+ String sourceFormat,
+ GenericTable, ?> sourceTable,
+ Map sourceOptions,
+ List targetFormats,
+ Map> targetOptions,
+ Integer expectedCount,
+ String filterCondition) {
+ Dataset sourceRows =
+ sparkSession
+ .read()
+ .options(sourceOptions)
+ .format(sourceFormat.toLowerCase())
+ .load(sourceTable.getBasePath())
+ .orderBy(sourceTable.getOrderByColumn())
+ .filter(filterCondition);
+ Map> targetRowsByFormat =
+ targetFormats.stream()
+ .collect(
+ Collectors.toMap(
+ Function.identity(),
+ targetFormat -> {
+ Map finalTargetOptions =
+ targetOptions.getOrDefault(targetFormat, Collections.emptyMap());
+ if (targetFormat.equals(HUDI)) {
+ finalTargetOptions = new HashMap<>(finalTargetOptions);
+ finalTargetOptions.put(HoodieMetadataConfig.ENABLE.key(), "true");
+ finalTargetOptions.put(
+ "hoodie.datasource.read.extract.partition.values.from.path", "true");
+ // The file group reader returns unexpected results for these reads.
+ finalTargetOptions.put(
+ HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key(), "false");
+ }
+ return sparkSession
+ .read()
+ .options(finalTargetOptions)
+ .format(targetFormat.toLowerCase())
+ .load(sourceTable.getDataPath())
+ .orderBy(sourceTable.getOrderByColumn())
+ .filter(filterCondition);
+ }));
+
+ List dataset1Rows =
+ sourceRows
+ .selectExpr(getSelectColumnsArr(sourceTable.getColumnsToSelect(), sourceFormat))
+ .toJSON()
+ .collectAsList();
+ targetRowsByFormat.forEach(
+ (format, targetRows) -> {
+ List dataset2Rows =
+ targetRows
+ .selectExpr(getSelectColumnsArr(sourceTable.getColumnsToSelect(), format))
+ .toJSON()
+ .collectAsList();
+ assertEquals(
+ dataset1Rows.size(),
+ dataset2Rows.size(),
+ String.format(
+ "Datasets have different row counts when reading from Spark. Source: %s, Target: %s",
+ sourceFormat, format));
+ // sanity check the count to ensure test is set up properly
+ if (expectedCount != null) {
+ assertEquals(expectedCount, dataset1Rows.size());
+ } else {
+ // if count is not known ahead of time, ensure datasets are non-empty
+ assertFalse(dataset1Rows.isEmpty());
+ }
+
+ if (containsUUIDFields(dataset1Rows) && containsUUIDFields(dataset2Rows)) {
+ compareDatasetWithUUID(dataset1Rows, dataset2Rows);
+ } else {
+ assertEquals(
+ dataset1Rows,
+ dataset2Rows,
+ String.format(
+ "Datasets are not equivalent when reading from Spark. Source: %s, Target: %s",
+ sourceFormat, format));
+ }
+ });
+ }
+
+ /**
+ * Compares two datasets where dataset1Rows is for Iceberg and dataset2Rows is for other formats
+ * (such as Delta or Hudi). - For the "uuid_field", if present, the UUID from dataset1 (Iceberg)
+ * is compared with the Base64-encoded UUID from dataset2 (other formats), after decoding. - For
+ * all other fields, the values are compared directly. - If neither row contains the "uuid_field",
+ * the rows are compared as plain JSON strings.
+ *
+ * @param dataset1Rows List of JSON rows representing the dataset in Iceberg format (UUID is
+ * stored as a string).
+ * @param dataset2Rows List of JSON rows representing the dataset in other formats (UUID might be
+ * Base64-encoded).
+ */
+ private void compareDatasetWithUUID(List dataset1Rows, List dataset2Rows) {
+ for (int i = 0; i < dataset1Rows.size(); i++) {
+ String row1 = dataset1Rows.get(i);
+ String row2 = dataset2Rows.get(i);
+ if (row1.contains("uuid_field") && row2.contains("uuid_field")) {
+ try {
+ JsonNode node1 = OBJECT_MAPPER.readTree(row1);
+ JsonNode node2 = OBJECT_MAPPER.readTree(row2);
+
+ // check uuid field
+ String uuidStr1 = node1.get("uuid_field").asText();
+ byte[] bytes = Base64.getDecoder().decode(node2.get("uuid_field").asText());
+ ByteBuffer bb = ByteBuffer.wrap(bytes);
+ UUID uuid2 = new UUID(bb.getLong(), bb.getLong());
+ String uuidStr2 = uuid2.toString();
+ assertEquals(
+ uuidStr1,
+ uuidStr2,
+ String.format(
+ "Datasets are not equivalent when reading from Spark. Source: %s, Target: %s",
+ uuidStr1, uuidStr2));
+
+ // check other fields
+ ((ObjectNode) node1).remove("uuid_field");
+ ((ObjectNode) node2).remove("uuid_field");
+ assertEquals(
+ node1.toString(),
+ node2.toString(),
+ String.format(
+ "Datasets are not equivalent when comparing other fields. Source: %s, Target: %s",
+ node1, node2));
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
+ }
+ } else {
+ assertEquals(
+ row1,
+ row2,
+ String.format(
+ "Datasets are not equivalent when reading from Spark. Source: %s, Target: %s",
+ row1, row2));
+ }
+ }
+ }
+
+ private static String[] getSelectColumnsArr(List columnsToSelect, String format) {
+ boolean isHudi = format.equals(HUDI);
+ boolean isIceberg = format.equals(ICEBERG);
+ return columnsToSelect.stream()
+ .map(
+ colName -> {
+ if (colName.startsWith("timestamp_local_millis")) {
+ if (isHudi) {
+ return String.format(
+ "unix_millis(CAST(%s AS TIMESTAMP)) AS %s", colName, colName);
+ } else if (isIceberg) {
+ // iceberg is showing up as micros, so we need to divide by 1000 to get millis
+ return String.format("%s div 1000 AS %s", colName, colName);
+ } else {
+ return colName;
+ }
+ } else if (isHudi && colName.startsWith("timestamp_local_micros")) {
+ return String.format("unix_micros(CAST(%s AS TIMESTAMP)) AS %s", colName, colName);
+ } else {
+ return colName;
+ }
+ })
+ .toArray(String[]::new);
+ }
+
+ private boolean containsUUIDFields(List rows) {
+ for (String row : rows) {
+ if (row.contains("\"uuid_field\"")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Stream addBasicPartitionCases(Stream arguments) {
+ // add unpartitioned and partitioned cases
+ return arguments.flatMap(
+ args -> {
+ Object[] unpartitionedArgs = Arrays.copyOf(args.get(), args.get().length + 1);
+ unpartitionedArgs[unpartitionedArgs.length - 1] = PartitionConfig.of(null, null);
+ Object[] partitionedArgs = Arrays.copyOf(args.get(), args.get().length + 1);
+ partitionedArgs[partitionedArgs.length - 1] =
+ PartitionConfig.of("level:SIMPLE", "level:VALUE");
+ return Stream.of(
+ Arguments.arguments(unpartitionedArgs), Arguments.arguments(partitionedArgs));
+ });
+ }
+
+ private static TableFormatPartitionDataHolder buildArgsForPartition(
+ String sourceFormat,
+ String targetFormat,
+ String hudiPartitionConfig,
+ String xTablePartitionConfig,
+ String filter) {
+ return TableFormatPartitionDataHolder.builder()
+ .sourceTableFormat(sourceFormat)
+ .targetTableFormat(targetFormat)
+ .hudiSourceConfig(Optional.ofNullable(hudiPartitionConfig))
+ .xTablePartitionConfig(xTablePartitionConfig)
+ .filter(filter)
+ .build();
+ }
+
+ @Builder
+ @Value
+ private static class TableFormatPartitionDataHolder {
+ String sourceTableFormat;
+ String targetTableFormat;
+ String xTablePartitionConfig;
+ Optional hudiSourceConfig;
+ String filter;
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergVariousActions.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergVariousActions.java
new file mode 100644
index 000000000..d8021ca91
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/ITIcebergVariousActions.java
@@ -0,0 +1,752 @@
+/*
+ * 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.xtable;
+
+import static java.util.stream.Collectors.groupingBy;
+import static org.apache.hudi.hadoop.fs.HadoopFSUtils.getStorageConf;
+import static org.apache.xtable.testutil.ITTestUtils.validateTable;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.Closeable;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import lombok.SneakyThrows;
+
+import org.apache.avro.Schema;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.spark.SparkConf;
+import org.apache.spark.api.java.JavaSparkContext;
+import org.apache.spark.sql.SparkSession;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import org.apache.hudi.client.HoodieReadClient;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+
+import org.apache.xtable.hudi.HudiConversionSource;
+import org.apache.xtable.hudi.HudiInstantUtils;
+import org.apache.xtable.hudi.HudiSourceConfig;
+import org.apache.xtable.hudi.HudiTestUtil;
+import org.apache.xtable.hudi.PathBasedPartitionSpecExtractor;
+import org.apache.xtable.model.CommitsBacklog;
+import org.apache.xtable.model.InstantsForIncrementalSync;
+import org.apache.xtable.model.InternalSnapshot;
+import org.apache.xtable.model.InternalTable;
+import org.apache.xtable.model.TableChange;
+import org.apache.xtable.model.schema.InternalField;
+import org.apache.xtable.model.schema.InternalSchema;
+import org.apache.xtable.model.schema.InternalType;
+import org.apache.xtable.model.storage.DataLayoutStrategy;
+import org.apache.xtable.model.storage.TableFormat;
+
+/**
+ * A suite of functional tests that the extraction from Hudi to Intermediate representation works.
+ */
+public class ITIcebergVariousActions {
+ @TempDir public static Path tempDir;
+ private static JavaSparkContext jsc;
+ private static SparkSession sparkSession;
+ private static final Configuration CONFIGURATION = new Configuration();
+
+ @BeforeAll
+ public static void setupOnce() {
+ SparkConf sparkConf = HudiTestUtil.getSparkConf(tempDir);
+ sparkSession =
+ SparkSession.builder().config(HoodieReadClient.addHoodieSupport(sparkConf)).getOrCreate();
+ sparkSession
+ .sparkContext()
+ .hadoopConfiguration()
+ .set("parquet.avro.write-old-list-structure", "false");
+ jsc = JavaSparkContext.fromSparkContext(sparkSession.sparkContext());
+ }
+
+ @AfterAll
+ public static void teardown() {
+ if (jsc != null) {
+ jsc.close();
+ }
+ if (sparkSession != null) {
+ sparkSession.close();
+ }
+ }
+
+ @Test
+ void getCurrentTableTest() {
+ String tableName = GenericTable.getTableName();
+ Path basePath = tempDir.resolve(tableName);
+ HudiTestUtil.PartitionConfig partitionConfig = HudiTestUtil.PartitionConfig.of(null, null);
+ Schema schema =
+ Schema.createRecord(
+ "testCurrentTable",
+ null,
+ "hudi",
+ false,
+ Arrays.asList(
+ new Schema.Field("key", Schema.create(Schema.Type.STRING)),
+ new Schema.Field("field1", Schema.create(Schema.Type.STRING)),
+ new Schema.Field("field2", Schema.create(Schema.Type.STRING))));
+ HudiConversionSource hudiClient = null;
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.withSchema(
+ tableName,
+ tempDir,
+ HudiTestUtil.PartitionConfig.of(null, null).getHudiConfig(),
+ HoodieTableType.COPY_ON_WRITE,
+ schema)) {
+ table.insertRecords(5, Collections.emptyList(), false);
+ hudiClient =
+ getHudiSourceClient(
+ CONFIGURATION, table.getBasePath(), partitionConfig.getXTableConfig());
+ InternalTable internalTable = hudiClient.getCurrentTable();
+ InternalSchema internalSchema =
+ InternalSchema.builder()
+ .name("testCurrentTable")
+ .dataType(InternalType.RECORD)
+ .isNullable(false)
+ .fields(
+ Arrays.asList(
+ InternalField.builder()
+ .name("_hoodie_commit_time")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(true)
+ .build())
+ .defaultValue(InternalField.Constants.NULL_DEFAULT_VALUE)
+ .build(),
+ InternalField.builder()
+ .name("_hoodie_commit_seqno")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(true)
+ .build())
+ .defaultValue(InternalField.Constants.NULL_DEFAULT_VALUE)
+ .build(),
+ InternalField.builder()
+ .name("_hoodie_record_key")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(true)
+ .build())
+ .defaultValue(InternalField.Constants.NULL_DEFAULT_VALUE)
+ .build(),
+ InternalField.builder()
+ .name("_hoodie_partition_path")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(true)
+ .build())
+ .defaultValue(InternalField.Constants.NULL_DEFAULT_VALUE)
+ .build(),
+ InternalField.builder()
+ .name("_hoodie_file_name")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(true)
+ .build())
+ .defaultValue(InternalField.Constants.NULL_DEFAULT_VALUE)
+ .build(),
+ InternalField.builder()
+ .name("key")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(false)
+ .build())
+ .defaultValue(null)
+ .build(),
+ InternalField.builder()
+ .name("field1")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(false)
+ .build())
+ .defaultValue(null)
+ .build(),
+ InternalField.builder()
+ .name("field2")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(false)
+ .build())
+ .defaultValue(null)
+ .build()))
+ .recordKeyFields(
+ Collections.singletonList(
+ InternalField.builder()
+ .name("key")
+ .schema(
+ InternalSchema.builder()
+ .name("string")
+ .dataType(InternalType.STRING)
+ .isNullable(false)
+ .build())
+ .defaultValue(null)
+ .build()))
+ .build();
+ validateTable(
+ internalTable,
+ tableName,
+ TableFormat.HUDI,
+ internalSchema,
+ DataLayoutStrategy.FLAT,
+ "file:" + basePath + "_v1",
+ internalTable.getLatestMetadataPath(),
+ Collections.emptyList());
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("testsForAllPartitions")
+ public void insertAndUpsertData(HudiTestUtil.PartitionConfig partitionConfig) {
+ String tableName = GenericTable.getTableName();
+ HudiConversionSource hudiClient = null;
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, partitionConfig.getHudiConfig(), HoodieTableType.COPY_ON_WRITE)) {
+ List> allBaseFilePaths = new ArrayList<>();
+ List allTableChanges = new ArrayList<>();
+
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1;
+ if (partitionConfig.getHudiConfig() != null) {
+ insertsForCommit1 = table.generateRecords(100, "INFO");
+ } else {
+ insertsForCommit1 = table.generateRecords(100);
+ }
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ if (partitionConfig.getHudiConfig() != null) {
+ table.insertRecords(100, "WARN", true);
+ } else {
+ table.insertRecords(100, true);
+ }
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.upsertRecords(insertsForCommit1.subList(0, 20), true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+ hudiClient =
+ getHudiSourceClient(
+ CONFIGURATION, table.getBasePath(), partitionConfig.getXTableConfig());
+ // Get the current snapshot
+ InternalSnapshot internalSnapshot = hudiClient.getCurrentSnapshot();
+ ValidationTestHelper.validateSnapshot(
+ internalSnapshot, allBaseFilePaths.get(allBaseFilePaths.size() - 1));
+ // Get second change in Incremental format.
+ InstantsForIncrementalSync instantsForIncrementalSync =
+ InstantsForIncrementalSync.builder()
+ .lastSyncInstant(HudiInstantUtils.parseFromInstantTime(commitInstant1))
+ .build();
+ CommitsBacklog instantCommitsBacklog =
+ hudiClient.getCommitsBacklog(instantsForIncrementalSync);
+ for (HoodieInstant instant : instantCommitsBacklog.getCommitsToProcess()) {
+ TableChange tableChange = hudiClient.getTableChangeForCommit(instant);
+ allTableChanges.add(tableChange);
+ }
+ ValidationTestHelper.validateTableChanges(allBaseFilePaths, allTableChanges);
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @Test
+ public void testOnlyUpsertsAfterInserts() {
+ HoodieTableType tableType = HoodieTableType.COPY_ON_WRITE;
+ HudiTestUtil.PartitionConfig partitionConfig = HudiTestUtil.PartitionConfig.of(null, null);
+ String tableName = "test_table_" + UUID.randomUUID();
+ HudiConversionSource hudiClient = null;
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, partitionConfig.getHudiConfig(), tableType)) {
+ List> allBaseFilePaths = new ArrayList<>();
+ List allTableChanges = new ArrayList<>();
+
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1;
+ if (partitionConfig.getHudiConfig() != null) {
+ insertsForCommit1 = table.generateRecords(100, "INFO");
+ } else {
+ insertsForCommit1 = table.generateRecords(100);
+ }
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.upsertRecords(insertsForCommit1.subList(0, 20), true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+ table.deleteRecords(insertsForCommit1.subList(15, 30), true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ hudiClient =
+ getHudiSourceClient(
+ CONFIGURATION, table.getBasePath(), partitionConfig.getXTableConfig());
+ // Get the current snapshot
+ InternalSnapshot internalSnapshot = hudiClient.getCurrentSnapshot();
+ ValidationTestHelper.validateSnapshot(
+ internalSnapshot, allBaseFilePaths.get(allBaseFilePaths.size() - 1));
+ // Get second change in Incremental format.
+ InstantsForIncrementalSync instantsForIncrementalSync =
+ InstantsForIncrementalSync.builder()
+ .lastSyncInstant(HudiInstantUtils.parseFromInstantTime(commitInstant1))
+ .build();
+ CommitsBacklog instantCommitsBacklog =
+ hudiClient.getCommitsBacklog(instantsForIncrementalSync);
+ for (HoodieInstant instant : instantCommitsBacklog.getCommitsToProcess()) {
+ TableChange tableChange = hudiClient.getTableChangeForCommit(instant);
+ allTableChanges.add(tableChange);
+ }
+ ValidationTestHelper.validateTableChanges(allBaseFilePaths, allTableChanges);
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @Test
+ public void testForIncrementalSyncSafetyCheck() {
+ HoodieTableType tableType = HoodieTableType.COPY_ON_WRITE;
+ HudiTestUtil.PartitionConfig partitionConfig = HudiTestUtil.PartitionConfig.of(null, null);
+ String tableName = GenericTable.getTableName();
+ HudiConversionSource hudiClient = null;
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, partitionConfig.getHudiConfig(), tableType)) {
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1 = table.generateRecords(100);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+
+ table.upsertRecords(insertsForCommit1.subList(30, 40), true);
+
+ String commitInstant2 = table.startCommit();
+ List> insertsForCommit2 = table.generateRecords(100);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit2, commitInstant2, true);
+
+ table.clean(); // cleans up file groups from commitInstant1
+
+ hudiClient =
+ getHudiSourceClient(
+ CONFIGURATION, table.getBasePath(), partitionConfig.getXTableConfig());
+ // commitInstant1 is not safe for incremental sync as cleaner has run after and touched
+ // related files.
+ assertFalse(
+ hudiClient.isIncrementalSyncSafeFrom(
+ HudiInstantUtils.parseFromInstantTime(commitInstant1)));
+ // commitInstant2 is safe for incremental sync as cleaner has no affect on data written in
+ // this commit.
+ assertTrue(
+ hudiClient.isIncrementalSyncSafeFrom(
+ HudiInstantUtils.parseFromInstantTime(commitInstant2)));
+ // commit older by an hour is not present in table, hence not safe for incremental sync.
+ Instant instantAsOfHourAgo = Instant.now().minus(1, ChronoUnit.HOURS);
+ assertFalse(hudiClient.isIncrementalSyncSafeFrom(instantAsOfHourAgo));
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @Test
+ public void testsForDropPartition() {
+ String tableName = "test_table_" + UUID.randomUUID();
+ HudiConversionSource hudiClient = null;
+ try (TestSparkHudiTable table =
+ TestSparkHudiTable.forStandardSchema(
+ tableName, tempDir, jsc, "level:SIMPLE", HoodieTableType.COPY_ON_WRITE)) {
+ List> allBaseFilePaths = new ArrayList<>();
+ List allTableChanges = new ArrayList<>();
+
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1 = table.generateRecords(100);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.insertRecords(100, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ Map> recordsByPartition =
+ insertsForCommit1.stream().collect(groupingBy(HoodieRecord::getPartitionPath));
+ String partitionToDelete = recordsByPartition.keySet().stream().sorted().findFirst().get();
+
+ table.deletePartition(partitionToDelete, HoodieTableType.COPY_ON_WRITE);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ // Insert few records for deleted partition again to make it interesting.
+ table.insertRecords(20, partitionToDelete, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ hudiClient = getHudiSourceClient(CONFIGURATION, table.getBasePath(), "level:VALUE");
+ // Get the current snapshot
+ InternalSnapshot internalSnapshot = hudiClient.getCurrentSnapshot();
+ ValidationTestHelper.validateSnapshot(
+ internalSnapshot, allBaseFilePaths.get(allBaseFilePaths.size() - 1));
+ // Get changes in Incremental format.
+ InstantsForIncrementalSync instantsForIncrementalSync =
+ InstantsForIncrementalSync.builder()
+ .lastSyncInstant(HudiInstantUtils.parseFromInstantTime(commitInstant1))
+ .build();
+ CommitsBacklog instantCommitsBacklog =
+ hudiClient.getCommitsBacklog(instantsForIncrementalSync);
+ for (HoodieInstant instant : instantCommitsBacklog.getCommitsToProcess()) {
+ TableChange tableChange = hudiClient.getTableChangeForCommit(instant);
+ allTableChanges.add(tableChange);
+ }
+ ValidationTestHelper.validateTableChanges(allBaseFilePaths, allTableChanges);
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @SneakyThrows
+ @Test
+ public void testsForDeleteAllRecordsInPartition() {
+ String tableName = "test_table_" + UUID.randomUUID();
+ HudiConversionSource hudiClient = null;
+ try (TestSparkHudiTable table =
+ TestSparkHudiTable.forStandardSchema(
+ tableName, tempDir, jsc, "level:SIMPLE", HoodieTableType.COPY_ON_WRITE)) {
+ List> allBaseFilePaths = new ArrayList<>();
+ List allTableChanges = new ArrayList<>();
+ HoodieTableMetaClient metaClient =
+ HoodieTableMetaClient.builder()
+ .setBasePath(table.getBasePath())
+ .setLoadActiveTimelineOnLoad(true)
+ .setConf(getStorageConf(jsc.hadoopConfiguration()))
+ .build();
+
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1 = table.generateRecords(100);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.insertRecords(100, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ Map>> recordsByPartition =
+ insertsForCommit1.stream().collect(groupingBy(HoodieRecord::getPartitionPath));
+ String selectedPartition = recordsByPartition.keySet().stream().sorted().findAny().get();
+ table.deleteRecords(recordsByPartition.get(selectedPartition), true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ // Insert few records for deleted partition again to make it interesting.
+ table.insertRecords(20, selectedPartition, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ hudiClient = getHudiSourceClient(CONFIGURATION, table.getBasePath(), "level:VALUE");
+ // Get the current snapshot
+ InternalSnapshot internalSnapshot = hudiClient.getCurrentSnapshot();
+ ValidationTestHelper.validateSnapshot(
+ internalSnapshot, allBaseFilePaths.get(allBaseFilePaths.size() - 1));
+ // Get changes in Incremental format.
+ InstantsForIncrementalSync instantsForIncrementalSync =
+ InstantsForIncrementalSync.builder()
+ .lastSyncInstant(HudiInstantUtils.parseFromInstantTime(commitInstant1))
+ .build();
+ CommitsBacklog instantCommitsBacklog =
+ hudiClient.getCommitsBacklog(instantsForIncrementalSync);
+ for (HoodieInstant instant : instantCommitsBacklog.getCommitsToProcess()) {
+ TableChange tableChange = hudiClient.getTableChangeForCommit(instant);
+ allTableChanges.add(tableChange);
+ }
+ ValidationTestHelper.validateTableChanges(allBaseFilePaths, allTableChanges);
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("testsForAllPartitions")
+ public void testsForClustering(HudiTestUtil.PartitionConfig partitionConfig) {
+ String tableName = "test_table_" + UUID.randomUUID();
+ HudiConversionSource hudiClient = null;
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, partitionConfig.getHudiConfig(), HoodieTableType.COPY_ON_WRITE)) {
+ List> allBaseFilePaths = new ArrayList<>();
+ List allTableChanges = new ArrayList<>();
+
+ /*
+ * Insert 100 records.
+ * Insert 100 records.
+ * Upsert 20 records from first commit.
+ * Compact for MOR table.
+ * Insert 100 records.
+ * Run Clustering.
+ * Insert 100 records.
+ */
+
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1 = table.generateRecords(100);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.insertRecords(100, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.upsertRecords(insertsForCommit1.subList(0, 20), true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+ table.insertRecords(100, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.cluster();
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.insertRecords(100, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ hudiClient =
+ getHudiSourceClient(
+ CONFIGURATION, table.getBasePath(), partitionConfig.getXTableConfig());
+ // Get the current snapshot
+ InternalSnapshot internalSnapshot = hudiClient.getCurrentSnapshot();
+ ValidationTestHelper.validateSnapshot(
+ internalSnapshot, allBaseFilePaths.get(allBaseFilePaths.size() - 1));
+ // Get changes in Incremental format.
+ InstantsForIncrementalSync instantsForIncrementalSync =
+ InstantsForIncrementalSync.builder()
+ .lastSyncInstant(
+ HudiInstantUtils.parseFromInstantTime(
+ table
+ .getMetaClient()
+ .getActiveTimeline()
+ .firstInstant()
+ .get()
+ .requestedTime()))
+ .build();
+
+ CommitsBacklog instantCommitsBacklog =
+ hudiClient.getCommitsBacklog(instantsForIncrementalSync);
+ for (HoodieInstant instant : instantCommitsBacklog.getCommitsToProcess()) {
+ TableChange tableChange = hudiClient.getTableChangeForCommit(instant);
+ allTableChanges.add(tableChange);
+ }
+ List> baseFilesForInstantsNotSynced =
+ allBaseFilePaths.subList(
+ allBaseFilePaths.size() - allTableChanges.size() - 1, allBaseFilePaths.size());
+ ValidationTestHelper.validateTableChanges(baseFilesForInstantsNotSynced, allTableChanges);
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("testsForAllPartitions")
+ @Disabled(
+ "Savepoint and restore are not represented in Iceberg metadata yet. A savepoint changes no"
+ + " data, so no snapshot records it and the reconstructed timeline reports the completed"
+ + " savepoint instant as inflight. Tracked as a follow-up.")
+ public void testsForSavepointRestore(HudiTestUtil.PartitionConfig partitionConfig) {
+ String tableName = "test_table_" + UUID.randomUUID();
+ HudiConversionSource hudiClient = null;
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, partitionConfig.getHudiConfig(), HoodieTableType.COPY_ON_WRITE)) {
+ List> allBaseFilePaths = new ArrayList<>();
+ List allTableChanges = new ArrayList<>();
+
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1 = table.generateRecords(50);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ // This is the commit we're going to savepoint and restore to
+ table.insertRecords(50, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ List> recordList = table.insertRecords(50, true);
+ Set baseFilePaths = new HashSet<>(table.getAllLatestBaseFilePaths());
+ table.upsertRecords(recordList.subList(0, 20), true);
+ baseFilePaths.addAll(table.getAllLatestBaseFilePaths());
+ // Note that restore removes all the new base files added by these two commits
+ allBaseFilePaths.add(new ArrayList<>(baseFilePaths));
+
+ table.savepointRestoreFromNthMostRecentInstant(2);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ table.insertRecords(50, true);
+ allBaseFilePaths.add(table.getAllLatestBaseFilePaths());
+
+ hudiClient =
+ getHudiSourceClient(
+ CONFIGURATION, table.getBasePath(), partitionConfig.getXTableConfig());
+ // Get the current snapshot
+ InternalSnapshot internalSnapshot = hudiClient.getCurrentSnapshot();
+ ValidationTestHelper.validateSnapshot(
+ internalSnapshot, allBaseFilePaths.get(allBaseFilePaths.size() - 1));
+ // Get changes in Incremental format.
+ InstantsForIncrementalSync instantsForIncrementalSync =
+ InstantsForIncrementalSync.builder()
+ .lastSyncInstant(HudiInstantUtils.parseFromInstantTime(commitInstant1))
+ .build();
+ CommitsBacklog instantCommitsBacklog =
+ hudiClient.getCommitsBacklog(instantsForIncrementalSync);
+ for (HoodieInstant instant : instantCommitsBacklog.getCommitsToProcess()) {
+ TableChange tableChange = hudiClient.getTableChangeForCommit(instant);
+ allTableChanges.add(tableChange);
+ }
+
+ IntStream.range(0, allTableChanges.size() - 1)
+ .forEach(
+ i -> {
+ if (i == 1) {
+ // Savepoint: no change
+ ValidationTestHelper.validateTableChange(
+ allBaseFilePaths.get(i), allBaseFilePaths.get(i), allTableChanges.get(i));
+ } else {
+ ValidationTestHelper.validateTableChange(
+ allBaseFilePaths.get(i), allBaseFilePaths.get(i + 1), allTableChanges.get(i));
+ }
+ });
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("testsForAllPartitions")
+ public void testsForRollbacks(HudiTestUtil.PartitionConfig partitionConfig) {
+ String tableName = "test_table_" + UUID.randomUUID();
+ HudiConversionSource hudiClient = null;
+ try (TestJavaHudiTable table =
+ TestJavaHudiTable.forStandardSchema(
+ tableName, tempDir, partitionConfig.getHudiConfig(), HoodieTableType.COPY_ON_WRITE)) {
+
+ String commitInstant1 = table.startCommit();
+ List> insertsForCommit1 = table.generateRecords(50);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit1, commitInstant1, true);
+ List baseFilesAfterCommit1 = table.getAllLatestBaseFilePaths();
+
+ String commitInstant2 = table.startCommit();
+ List> insertsForCommit2 = table.generateRecords(50);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit2, commitInstant2, true);
+ List baseFilesAfterCommit2 = table.getAllLatestBaseFilePaths();
+
+ String commitInstant3 = table.startCommit();
+ List> insertsForCommit3 = table.generateRecords(50);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit3, commitInstant3, true);
+ List baseFilesAfterCommit3 = table.getAllLatestBaseFilePaths();
+
+ table.rollback(commitInstant3);
+ List baseFilesAfterRollback = table.getAllLatestBaseFilePaths();
+
+ String commitInstant4 = table.startCommit();
+ List> insertsForCommit4 = table.generateRecords(50);
+ table.insertRecordsWithCommitAlreadyStarted(insertsForCommit4, commitInstant4, true);
+ List baseFilesAfterCommit4 = table.getAllLatestBaseFilePaths();
+
+ hudiClient =
+ getHudiSourceClient(
+ CONFIGURATION, table.getBasePath(), partitionConfig.getXTableConfig());
+ // Get the current snapshot
+ InternalSnapshot internalSnapshot = hudiClient.getCurrentSnapshot();
+ ValidationTestHelper.validateSnapshot(internalSnapshot, baseFilesAfterCommit4);
+ // Get changes in Incremental format.
+ InstantsForIncrementalSync instantsForIncrementalSync =
+ InstantsForIncrementalSync.builder()
+ .lastSyncInstant(HudiInstantUtils.parseFromInstantTime(commitInstant1))
+ .build();
+ CommitsBacklog instantCommitsBacklog =
+ hudiClient.getCommitsBacklog(instantsForIncrementalSync);
+ for (HoodieInstant instant : instantCommitsBacklog.getCommitsToProcess()) {
+ TableChange tableChange = hudiClient.getTableChangeForCommit(instant);
+ if (commitInstant2.equals(instant.requestedTime())) {
+ ValidationTestHelper.validateTableChange(
+ baseFilesAfterCommit1, baseFilesAfterCommit2, tableChange);
+ } else if ("rollback".equals(instant.getAction())) {
+ ValidationTestHelper.validateTableChange(
+ baseFilesAfterCommit3, baseFilesAfterRollback, tableChange);
+ } else if (commitInstant4.equals(instant.requestedTime())) {
+ ValidationTestHelper.validateTableChange(
+ baseFilesAfterRollback, baseFilesAfterCommit4, tableChange);
+ } else {
+ fail("Please add proper asserts here");
+ }
+ }
+ } finally {
+ safeClose(hudiClient);
+ }
+ }
+
+ private static Stream testsForAllPartitions() {
+ HudiTestUtil.PartitionConfig unPartitionedConfig = HudiTestUtil.PartitionConfig.of(null, null);
+ HudiTestUtil.PartitionConfig partitionedConfig =
+ HudiTestUtil.PartitionConfig.of("level:SIMPLE", "level:VALUE");
+ List partitionConfigs =
+ Arrays.asList(unPartitionedConfig, partitionedConfig);
+ return partitionConfigs.stream().map(Arguments::of);
+ }
+
+ private HudiConversionSource getHudiSourceClient(
+ Configuration conf, String basePath, String xTablePartitionConfig) {
+ HoodieTableMetaClient hoodieTableMetaClient =
+ HoodieTableMetaClient.builder()
+ .setConf(getStorageConf(conf))
+ .setBasePath(basePath)
+ .setLoadActiveTimelineOnLoad(true)
+ .build();
+ PathBasedPartitionSpecExtractor partitionSpecExtractor =
+ HudiSourceConfig.fromPartitionFieldSpecConfig(xTablePartitionConfig)
+ .loadSourcePartitionSpecExtractor();
+ return new HudiConversionSource(hoodieTableMetaClient, partitionSpecExtractor);
+ }
+
+ @SneakyThrows
+ private void safeClose(Closeable closeable) {
+ if (closeable != null) {
+ closeable.close();
+ }
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/TestIcebergTableFormatDiscovery.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/TestIcebergTableFormatDiscovery.java
new file mode 100644
index 000000000..b5ebe6d98
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/TestIcebergTableFormatDiscovery.java
@@ -0,0 +1,102 @@
+/*
+ * 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.xtable;
+
+import static org.apache.hudi.hadoop.fs.HadoopFSUtils.getStorageConf;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.nio.file.Path;
+import java.util.Properties;
+
+import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+
+/**
+ * Verifies that Hudi resolves {@link IcebergTableFormat} through the ServiceLoader when the table
+ * config carries {@code hoodie.table.format=ICEBERG}. This isolates format discovery from the write
+ * path.
+ */
+class TestIcebergTableFormatDiscovery {
+
+ @TempDir public static Path tempDir;
+
+ @Test
+ void resolvesIcebergFormatFromTableConfig() throws Exception {
+ assertResolvedFormat(HoodieTableVersion.EIGHT, "table_v8");
+ }
+
+ @Test
+ void resolvesIcebergFormatOnTableVersionSix() throws Exception {
+ assertResolvedFormat(HoodieTableVersion.SIX, "table_v6");
+ }
+
+ @Test
+ void defaultsToNativeFormatWhenUnset() throws Exception {
+ String basePath = tempDir.resolve("table_native").toString();
+ Configuration conf = new Configuration();
+ HoodieTableMetaClient.newTableBuilder()
+ .setTableName("table_native")
+ .setTableType(HoodieTableType.COPY_ON_WRITE)
+ .setRecordKeyFields("id")
+ .initTable(getStorageConf(conf), basePath);
+
+ HoodieTableMetaClient metaClient =
+ HoodieTableMetaClient.builder().setConf(getStorageConf(conf)).setBasePath(basePath).build();
+ assertEquals("native", metaClient.getTableFormat().getName());
+ }
+
+ private void assertResolvedFormat(HoodieTableVersion tableVersion, String tableName)
+ throws Exception {
+ String basePath = tempDir.resolve(tableName).toString();
+ Configuration conf = new Configuration();
+
+ Properties properties = new Properties();
+ properties.put(
+ HoodieTableConfig.TABLE_FORMAT.key(), org.apache.xtable.model.storage.TableFormat.ICEBERG);
+
+ HoodieTableMetaClient.newTableBuilder()
+ .fromProperties(properties)
+ .setTableName(tableName)
+ .setTableType(HoodieTableType.COPY_ON_WRITE)
+ .setTableVersion(tableVersion)
+ .setRecordKeyFields("id")
+ .initTable(getStorageConf(conf), basePath);
+
+ HoodieTableMetaClient metaClient =
+ HoodieTableMetaClient.builder().setConf(getStorageConf(conf)).setBasePath(basePath).build();
+
+ // the value must survive a round trip through hoodie.properties
+ assertEquals(
+ org.apache.xtable.model.storage.TableFormat.ICEBERG,
+ metaClient.getTableConfig().getString(HoodieTableConfig.TABLE_FORMAT),
+ "hoodie.table.format was not persisted into hoodie.properties");
+
+ // and the ServiceLoader must then resolve our implementation
+ assertEquals(
+ org.apache.xtable.model.storage.TableFormat.ICEBERG,
+ metaClient.getTableFormat().getName(),
+ "ServiceLoader did not resolve IcebergTableFormat");
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/TestIcebergTableFormatWiring.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/TestIcebergTableFormatWiring.java
new file mode 100644
index 000000000..0bd96ed16
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/TestIcebergTableFormatWiring.java
@@ -0,0 +1,50 @@
+/*
+ * 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.xtable;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.xtable.metadata.IcebergMetadataFactory;
+import org.apache.xtable.model.storage.TableFormat;
+import org.apache.xtable.timeline.IcebergTimelineFactory;
+
+class TestIcebergTableFormatWiring {
+
+ private static IcebergTableFormat tableFormat() {
+ IcebergTableFormat tableFormat = new IcebergTableFormat();
+ tableFormat.init(new Properties());
+ return tableFormat;
+ }
+
+ @Test
+ void nameMatchesTheValueWrittenToHoodieProperties() {
+ assertEquals(TableFormat.ICEBERG, tableFormat().getName());
+ }
+
+ @Test
+ void suppliesTheIcebergTimelineAndMetadataFactories() {
+ assertInstanceOf(IcebergTimelineFactory.class, tableFormat().getTimelineFactory());
+ assertInstanceOf(IcebergMetadataFactory.class, tableFormat().getMetadataFactory());
+ }
+}
diff --git a/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/timeline/TestIcebergActiveTimeline.java b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/timeline/TestIcebergActiveTimeline.java
new file mode 100644
index 000000000..9d4acc0e3
--- /dev/null
+++ b/xtable-hudi-support/xtable-iceberg-pluggable-tf/src/test/java/org/apache/xtable/timeline/TestIcebergActiveTimeline.java
@@ -0,0 +1,74 @@
+/*
+ * 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.xtable.timeline;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import org.apache.hudi.common.table.timeline.versioning.v2.InstantComparatorV2;
+
+class TestIcebergActiveTimeline {
+
+ @Test
+ void instantKeySeparatesASavepointFromTheCommitItSavepoints() {
+ // Savepointing a commit produces a savepoint instant at that commit's own requested time.
+ String sharedRequestedTime = "20260819224951993";
+ assertNotEquals(
+ IcebergActiveTimeline.instantKey(
+ instant(HoodieTimeline.COMMIT_ACTION, sharedRequestedTime)),
+ IcebergActiveTimeline.instantKey(
+ instant(HoodieTimeline.SAVEPOINT_ACTION, sharedRequestedTime)),
+ "keying by requested time alone collides the two and drops one from the timeline");
+ }
+
+ @Test
+ void instantKeyIgnoresCompletionTimeAndState() {
+ HoodieInstant completed =
+ new HoodieInstant(
+ HoodieInstant.State.COMPLETED,
+ HoodieTimeline.COMMIT_ACTION,
+ "20260819224951993",
+ "20260819224956869",
+ InstantComparatorV2.REQUESTED_TIME_BASED_COMPARATOR);
+ HoodieInstant inflight =
+ new HoodieInstant(
+ HoodieInstant.State.INFLIGHT,
+ HoodieTimeline.COMMIT_ACTION,
+ "20260819224951993",
+ "20260819999999999",
+ InstantComparatorV2.REQUESTED_TIME_BASED_COMPARATOR);
+ assertEquals(
+ IcebergActiveTimeline.instantKey(completed),
+ IcebergActiveTimeline.instantKey(inflight),
+ "the same action at the same requested time is one instant regardless of its state");
+ }
+
+ private static HoodieInstant instant(String action, String requestedTime) {
+ return new HoodieInstant(
+ HoodieInstant.State.COMPLETED,
+ action,
+ requestedTime,
+ requestedTime,
+ InstantComparatorV2.REQUESTED_TIME_BASED_COMPARATOR);
+ }
+}