vinishjail97 commented on code in PR #894:
URL: https://github.com/apache/incubator-xtable/pull/894#discussion_r3825121923


##########
xtable-api/src/main/java/org/apache/xtable/model/InternalTable.java:
##########
@@ -52,4 +52,6 @@ public class InternalTable {
   Instant latestCommitTime;
   // Path to latest metadata
   String latestMetadataPath;
+  // latest operation on the table.
+  String latestTableOperationId;

Review Comment:
   If I traced this right, the value written here is 
`MAPPER.writeValueAsString(InstantDTO.fromInstant(instant))` from 
`HudiTableExtractor#generateTableOperationId`, and three classes in the new 
module read it back with `MAPPER.readValue(..., InstantDTO.class)`.
   
   That would mean a model in `xtable-api` carrying a Hudi-serialised type as 
an opaque `String`. I do not think it breaks anything today, and I understand 
the appeal of not inventing a new abstraction for one caller. My only worry is 
the next reader: the name does not say what the payload is, and nothing warns 
that Hudi could change the `InstantDTO` shape.
   
   Would you mind expanding `// latest operation on the table.` into a short 
Javadoc, saying who writes it, who reads it, and that the contents are 
format-specific and other targets should treat it as opaque? That would be 
enough for me.



##########
xtable-api/src/main/java/org/apache/xtable/model/metadata/TableSyncMetadata.java:
##########
@@ -56,6 +56,7 @@ public class TableSyncMetadata {
   int version;
   String sourceTableFormat;
   String sourceIdentifier;
+  String latestTableOperationId;

Review Comment:
   I may be misreading the compatibility story here, so please correct me.
   
   `CURRENT_VERSION` stays at `0`, and `MAPPER` in this class does not set 
`FAIL_ON_UNKNOWN_PROPERTIES` to false. If I follow that correctly, a 0.4.0 
reader that opens a table a newer writer has synced would find 
`latestTableOperationId` in the payload, fail on the unknown property, and 
surface it as `ParseException` from `fromJson`. Since this blob lives in the 
Iceberg snapshot summary and in the Delta table properties, that would affect 
targets beyond Iceberg.
   
   Would it be reasonable to disable `FAIL_ON_UNKNOWN_PROPERTIES` on this 
mapper, and add a round-trip test for a version-0 payload both with and without 
the new field? I am flagging it mainly because it is the one change in the PR 
that a user could not work around by reverting the jar, so it seemed worth 
being sure about.



##########
xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergActiveTimeline.java:
##########
@@ -0,0 +1,166 @@
+/*
+ * 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.HoodieTimeline;
+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<String> 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);
+  }
+
+  /**
+   * Whether an action adds or removes data files, and therefore has to be 
recorded in an Iceberg
+   * snapshot before it counts as completed. Savepoint and restore change no 
data, so an Iceberg
+   * snapshot never records them and their state is taken from the Hudi 
timeline as-is.
+   */
+  static boolean changesDataFiles(String action) {
+    return !HoodieTimeline.SAVEPOINT_ACTION.equals(action)
+        && !HoodieTimeline.RESTORE_ACTION.equals(action);
+  }
+
+  /**
+   * 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<HoodieInstant> getInstantsFromFileSystem(
+      HoodieTableMetaClient metaClient,
+      Set<String> includedExtensions,
+      boolean applyLayoutFilters) {
+    List<HoodieInstant> 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<String, HoodieInstant> instantsFromIceberg = new HashMap<>();
+    for (Snapshot snapshot : icebergTable.snapshots()) {

Review Comment:
   A question rather than a finding, since I am not certain of the Iceberg 
semantics here.
   
   My understanding is that `Table#snapshots()` returns every snapshot retained 
in table metadata rather than the ancestry of `currentSnapshot()`. If that is 
right, then after `IcebergRollbackExecutor` calls 
`rollbackTo(parentSnapshotId)` the rolled-back snapshot would remain in this 
iteration until it is expired, and its instant would keep being reported as 
completed by this timeline.
   
   Can we add a test that rolls back and then asserts the timeline is expected? 
I looked through `ITIcebergVariousActions` and could not find one.



##########
xtable-core/src/main/java/org/apache/xtable/hudi/HudiDataFileExtractor.java:
##########
@@ -145,6 +164,110 @@ public InternalFilesDiff getDiffForCommit(
     return 
InternalFilesDiff.builder().filesAdded(filesAdded).filesRemoved(filesRemoved).build();
   }
 
+  public InternalFilesDiff getDiffForCommit(

Review Comment:
   Readability suggestion only. This overloads `getDiffForCommit` with a 
different parameter set and also a different order: the existing one on line 
147 is `(HoodieInstant, InternalTable, HoodieInstant, HoodieTimeline)`, this 
one `(InternalTable, HoodieCommitMetadata, HoodieInstant)`. I misread a call 
site on my first pass because `InternalTable` moves from second to first 
position.
   
   Since the semantics differ too, this one deriving the diff from commit 
metadata rather than by diffing timeline state, would a distinct name such as 
`getDiffFromCommitMetadata` be clearer? Same thought for 
`getDiffForReplaceCommit`.
   
   Both new methods are public and undocumented; a Javadoc noting the required 
constructor and that log files are skipped would help a future reader.



##########
xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergTimelineArchiver.java:
##########
@@ -0,0 +1,109 @@
+/*
+ * 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.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+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.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<HoodieInstant> 
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());
+      Set<String> savepointedInstantTimes =
+          metaClient
+              .getActiveTimeline()
+              .getSavePointTimeline()
+              .filterCompletedInstants()
+              .getInstantsAsStream()
+              .map(HoodieInstant::requestedTime)
+              .collect(Collectors.toSet());
+      List<Long> expireSnapshots = new ArrayList<>();
+      for (Snapshot snapshot : table.snapshots()) {
+        TableSyncMetadata syncMetadata =
+            
TableSyncMetadata.fromJson(snapshot.summary().get(TableSyncMetadata.XTABLE_METADATA))
+                .get();
+        HoodieInstant hoodieInstant =
+            InstantDTO.toInstant(
+                MAPPER.readValue(syncMetadata.getLatestTableOperationId(), 
InstantDTO.class),
+                metaClient.getInstantGenerator());
+        // A savepoint changes no data, so no snapshot carries the savepoint 
action itself. The
+        // savepointed commit's snapshot and everything newer has to survive 
for a restore to it to
+        // remain possible.
+        if (savepointedInstantTimes.contains(hoodieInstant.requestedTime())) {
+          log.warn(
+              "Not expiring the snapshot for {} or any newer snapshot because 
it is savepointed",
+              hoodieInstant);
+          break;

Review Comment:
   I would like to check an assumption behind this `break`.
   
   It looks like it relies on `table.snapshots()` being ordered oldest-first. 
That holds for a simple append history, since it is the order in 
`TableMetadata.snapshots`, but as far as I can tell Iceberg does not document 
it as a guarantee. If the order ever differed, an early `break` would keep 
snapshots that could have expired, which is harmless, but a late one would 
expire a snapshot protecting a savepoint, which would cost a restore.
   
   Given the second outcome, would sorting explicitly on 
`Snapshot#sequenceNumber` (or `timestampMillis`) before the loop be worth the 
small cost? A test that savepoints a middle commit and asserts the exact 
expired set would also make the intent clear.
   
   Separately, and only a preference: on line 96 a savepoint stopping expiry 
looks like expected steady-state behaviour rather than something an operator 
must act on, so `log.info` might fit better than `log.warn`.



##########
xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergConversionTarget.java:
##########
@@ -347,6 +348,26 @@ public Optional<String> getTargetCommitIdentifier(String 
sourceIdentifier) {
     return Optional.empty();
   }
 
+  public void expireSnapshotIds(List<Long> snapshotIds) {

Review Comment:
   Three small things on the two new public methods, all easy to disagree with.
   
   Both are public on a class other modules now depend on, and neither has a 
Javadoc. A line stating that `beginSync` must have run first would help, since 
both dereference `transaction` without a null check and an NPE there would not 
point at the real mistake.
   
   The teardown (`commitTransaction`, then nulling `transaction`, 
`internalTableState` and `tableSyncMetadata`) now appears three times in this 
class counting `completeSync`. A private `resetTransactionState()` might be 
worth it.
   
   And `expireSnapshotIds(Collections.emptyList())` still calls `commit()` and 
`commitTransaction()`, which I think writes a metadata version that changes 
nothing. `IcebergTimelineArchiver` calls this on every archive round, so an 
early return on an empty list may be worthwhile.



##########
xtable-api/src/main/java/org/apache/xtable/spi/sync/TableFormatSync.java:
##########
@@ -178,6 +179,11 @@ private SyncResult getSyncResult(
     fileSyncMethod.sync(conversionTarget);
     conversionTarget.completeSync();
 
+    log.info(

Review Comment:
   I don't think log is required..we already have success and failure logs in 
`TableFormatSync` and we can track duration there itself? 



##########
xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java:
##########
@@ -87,7 +90,31 @@ public ConversionTarget createConversionTargetForName(
         TableFormat.DELTA.equalsIgnoreCase(tableFormatName)
             && 
DeltaConversionTargetConfig.fromProperties(properties).isUseKernel();
     ServiceLoader<ConversionTarget> loader = 
ServiceLoader.load(ConversionTarget.class);
-    for (ConversionTarget target : loader) {
+    Iterator<ConversionTarget> iterator = loader.iterator();
+    while (true) {
+      ConversionTarget target;
+      try {
+        // hasNext() also resolves provider classes lazily, so it can throw

Review Comment:
   Can we prune these comments? The code is pretty obvious here and can be 
shortened to 1-2 lines.



##########
xtable-api/src/main/java/org/apache/xtable/model/InternalTable.java:
##########
@@ -52,4 +52,6 @@ public class InternalTable {
   Instant latestCommitTime;
   // Path to latest metadata
   String latestMetadataPath;
+  // latest operation on the table.
+  String latestTableOperationId;

Review Comment:
   @yihua Can we enhance the java doc for this field? 



##########
xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/IcebergTableFormat.java:
##########
@@ -0,0 +1,248 @@
+/*
+ * 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 com.google.common.collect.ImmutableMap;
+
+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<List<HoodieInstant>> 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));
+  }
+
+  /**
+   * A savepoint changes no data files, so there is nothing for Iceberg to 
record. Materializing it
+   * as a snapshot would put a non-data snapshot at the tip whose completion 
time is later than the
+   * commits it protects, which breaks the rollback path. {@link
+   * org.apache.xtable.timeline.IcebergActiveTimeline} takes savepoint 
instants from the Hudi
+   * timeline directly instead.
+   */
+  @Override
+  public void savepoint(
+      HoodieInstant instant,
+      HoodieEngineContext engineContext,
+      HoodieTableMetaClient metaClient,
+      FileSystemViewManager viewManager) {}
+
+  /**
+   * The per-instant rollbacks a restore performs already moved the Iceberg 
table back, and the
+   * restore instant itself changes no data files.
+   */
+  @Override
+  public void restore(
+      HoodieInstant restoreCompletedInstant,
+      HoodieEngineContext engineContext,
+      HoodieTableMetaClient metaClient,
+      FileSystemViewManager viewManager) {}
+
+  @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(ImmutableMap.of(target, tableSyncMetadata), 
changes);
+    } catch (Exception e) {
+      throw new UpdateException("Failed to update iceberg metadata", e);
+    }
+  }
+
+  private void archiveInstants(
+      HoodieTableMetaClient metaClient,
+      InternalTable internalTable,
+      List<HoodieInstant> 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))

Review Comment:
   @yihua Let's document and add validation on which hudi partition types are 
supported? Do we have test coverage for different partition types supported? 



##########
xtable-core/src/main/java/org/apache/xtable/hudi/HudiIncrementalTableChangeExtractor.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.hudi;
+
+import java.util.Collections;
+import java.util.Iterator;
+
+import lombok.AllArgsConstructor;
+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
+@AllArgsConstructor
+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.getDiffForReplaceCommit(
+              internalTable, (HoodieReplaceCommitMetadata) commitMetadata, 
completedInstant);
+    } else {
+      dataFilesDiff =
+          dataFileExtractor.getDiffForCommit(internalTable, commitMetadata, 
completedInstant);
+    }
+
+    Iterator<TableChange> 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<TableChange> tableChangeIterator =
+        Collections.singleton(
+                TableChange.builder()
+                    .tableAsOfChange(internalTable)
+                    .filesDiff(
+                        InternalFilesDiff.from(Collections.emptyList(), 
Collections.emptyList()))

Review Comment:
   @yihua For pluggable table format..can we add a test assertion that hudi 
clean action should maps to iceberg's expireSnapshot and files have been 
deleted both in hudi and iceberg metadata? 
   
   
https://github.com/apache/incubator-xtable/blob/main/xtable-core/src/main/java/org/apache/xtable/iceberg/IcebergConversionTarget.java#L292
 



##########
xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/timeline/IcebergRollbackExecutor.java:
##########
@@ -0,0 +1,111 @@
+/*
+ * 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.getLatestTableOperationId(), 
InstantDTO.class),
+              metaClient.getInstantGenerator());
+      if (latestHoodieInstantInIceberg.equals(instantToRollback)) {
+        // The instant to rollback is the one the current snapshot records, so 
un-publish it by
+        // making its parent current again.
+        // NOTE: This is equivalent to hudi restore and should be performed by 
killing all active
+        // writers.
+        Long parentSnapshotId = table.currentSnapshot().parentId();
+        if (parentSnapshotId == null) {
+          throw new IllegalStateException(
+              String.format(
+                  "Cannot roll back instant '%s' because the snapshot 
recording it is the first "
+                      + "snapshot of the table and has no parent to fall back 
to.",
+                  instantToRollback));
+        }
+        target.beginSync(internalTable);
+        target.rollbackToSnapshotId(parentSnapshotId);
+      } else if (InstantComparison.compareTimestamps(
+          latestHoodieInstantInIceberg.getCompletionTime(),
+          InstantComparison.LESSER_THAN,
+          instantToRollback.getCompletionTime())) {
+        // In this case, instantToRollback was not committed in iceberg, so we 
can will be ignoring
+        // it.
+        log.info(
+            "Ignoring rollback to instant {}' because it is not committed in 
Iceberg. Latest committed instant in Iceberg {}'",

Review Comment:
   Tiny nit, no action needed if you would rather not touch it: there are two 
stray apostrophes in this message, `instant {}'` and `Iceberg {}'`. The comment 
a few lines up also reads "we can will be ignoring it".



##########
xtable-core/src/main/java/org/apache/xtable/hudi/HudiTableExtractor.java:
##########
@@ -88,9 +95,62 @@ public InternalTable table(HoodieTableMetaClient metaClient, 
HoodieInstant commi
         .readSchema(canonicalSchema)
         .latestMetadataPath(metaClient.getMetaPath().toString())
         
.latestCommitTime(HudiInstantUtils.parseFromInstantTime(commit.requestedTime()))
+        .latestTableOperationId(generateTableOperationId(commit))
         .build();
   }
 
+  public InternalTable table(
+      HoodieTableMetaClient metaClient,
+      HoodieCommitMetadata commitMetadata,
+      HoodieInstant completedInstant) {
+    InternalSchema canonicalSchema = getCanonicalSchema(commitMetadata);
+    List<InternalPartitionField> partitionFields = 
partitionSpecExtractor.spec(canonicalSchema);
+    List<InternalField> 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())
+        .latestCommitTime(

Review Comment:
   I wanted to check whether this difference is deliberate. `latestCommitTime` 
is set from `completedInstant.getCompletionTime()` here, and from 
`commit.requestedTime()` in the existing overload on line 97. Those are 
different clocks, and the field name does not distinguish them.
   
   My concern is downstream: `TableFormatSync` writes this into 
`TableSyncMetadata.lastInstantSynced`, which the incremental-sync decision 
reads, so the two paths would report times for the same instant that do not 
line up.
   
   If the difference is intentional, would a comment here and a Javadoc on 
`InternalTable.latestCommitTime` saying which clock it holds be enough? If not, 
it may be worth making them agree.



##########
xtable-core/src/main/java/org/apache/xtable/hudi/HudiTableExtractor.java:
##########
@@ -88,9 +95,62 @@ public InternalTable table(HoodieTableMetaClient metaClient, 
HoodieInstant commi
         .readSchema(canonicalSchema)
         .latestMetadataPath(metaClient.getMetaPath().toString())
         
.latestCommitTime(HudiInstantUtils.parseFromInstantTime(commit.requestedTime()))
+        .latestTableOperationId(generateTableOperationId(commit))
         .build();
   }
 
+  public InternalTable table(
+      HoodieTableMetaClient metaClient,
+      HoodieCommitMetadata commitMetadata,
+      HoodieInstant completedInstant) {
+    InternalSchema canonicalSchema = getCanonicalSchema(commitMetadata);
+    List<InternalPartitionField> partitionFields = 
partitionSpecExtractor.spec(canonicalSchema);
+    List<InternalField> 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())
+        .latestCommitTime(
+            
HudiInstantUtils.parseFromInstantTime(completedInstant.getCompletionTime()))
+        .latestTableOperationId(generateTableOperationId(completedInstant))
+        .build();
+  }
+
+  private InternalSchema getCanonicalSchema(HoodieCommitMetadata 
commitMetadata) {
+    HoodieSchema writerSchema =
+        HoodieSchema.parse(commitMetadata.getExtraMetadata().get(SCHEMA_KEY));

Review Comment:
   `getExtraMetadata().get(SCHEMA_KEY)` returns null when a commit carries no 
writer schema, and `HoodieSchema.parse(null)` would then fail with an NPE 
naming neither the table nor the instant.
   
   The thing that drew my eye is the asymmetry: the sibling 
`getCanonicalSchema(metaClient, commit)` just below wraps its failure in 
`SchemaExtractorException` with the table name, and this overload has no 
wrapper at all, so the same class of failure would be reported quite 
differently depending on the path.
   
   Would a null check with a message naming the table and instant, plus 
wrapping in `SchemaExtractorException` to match, be reasonable?
   
   Two smaller notes, take or leave:
   - the `false` in `HoodieSchemaUtils.addMetadataFields(writerSchema, false)` 
on line 134 was opaque to me; a named local would make the call site 
self-explaining.
   - the two `getCanonicalSchema` overloads resolve the schema from genuinely 
different sources, so distinct names might save the next reader a lookup.



##########
xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/metadata/IcebergBackedTableMetadata.java:
##########
@@ -0,0 +1,31 @@
+/*
+ * 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;
+
+public class IcebergBackedTableMetadata extends FileSystemBackedTableMetadata {

Review Comment:
   As far as I can tell this subclass adds no behaviour: it forwards the 
constructor and overrides nothing, so `IcebergMetadataFactory` could return 
`FileSystemBackedTableMetadata` directly today.
   
   I assume the point is to reserve the type for the manifest-backed 
implementation the description mentions, which seems sensible. Would you mind 
adding a class Javadoc saying that, so a future reader does not remove it as 
dead weight? Noting that it lists the filesystem deliberately for now, that 
this is why the metadata table has to stay off, and that Iceberg manifests 
already carry the per-column bounds it should eventually read, would cover it.



##########
xtable-core/src/main/java/org/apache/xtable/hudi/HudiIncrementalTableChangeExtractor.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.hudi;
+
+import java.util.Collections;
+import java.util.Iterator;
+
+import lombok.AllArgsConstructor;
+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
+@AllArgsConstructor
+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.getDiffForReplaceCommit(
+              internalTable, (HoodieReplaceCommitMetadata) commitMetadata, 
completedInstant);
+    } else {
+      dataFilesDiff =
+          dataFileExtractor.getDiffForCommit(internalTable, commitMetadata, 
completedInstant);
+    }
+
+    Iterator<TableChange> 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<TableChange> tableChangeIterator =
+        Collections.singleton(
+                TableChange.builder()
+                    .tableAsOfChange(internalTable)
+                    .filesDiff(
+                        InternalFilesDiff.from(Collections.emptyList(), 
Collections.emptyList()))

Review Comment:
   This is the comment I am least sure about, so I would value your read on it.
   
   The clean path emits an empty `InternalFilesDiff`, so a Hudi CLEAN appears 
to produce an Iceberg snapshot that advances the recorded instant while 
removing nothing. If that is right, the files the clean deleted would stay in 
the Iceberg metadata as live data files pointing at paths that no longer exist, 
and an Iceberg reader would hit them on the next scan.
   
   It looks like the same class of issue as #824, which is still open, except 
applying to every clean rather than an incremental-sync edge case. 
`HoodieCleanMetadata` does carry the deleted paths, and this overload does not 
take that parameter.
   
   Could the clean path emit those as `filesRemoved`? If it is better as a 
follow-up, would you mind adding it to the limitations list in the description, 
since a reader seeing missing files feels different in kind from a missing 
feature.
   
   Unrelated nit: `@Value` already generates an all-args constructor, so 
`@AllArgsConstructor` on line 42 may be redundant.



##########
xtable-api/src/main/java/org/apache/xtable/model/metadata/TableSyncMetadata.java:
##########
@@ -56,6 +56,7 @@ public class TableSyncMetadata {
   int version;
   String sourceTableFormat;
   String sourceIdentifier;
+  String latestTableOperationId;

Review Comment:
   @yihua Can we rename this to `latestTableOperationIdentifier`? It's 
following the same pattern as `sourceIdentifier`. 



##########
xtable-hudi-support/xtable-iceberg-pluggable-tf/src/main/java/org/apache/xtable/IcebergTableFormat.java:
##########
@@ -0,0 +1,248 @@
+/*
+ * 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 com.google.common.collect.ImmutableMap;
+
+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<List<HoodieInstant>> 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));
+  }
+
+  /**
+   * A savepoint changes no data files, so there is nothing for Iceberg to 
record. Materializing it
+   * as a snapshot would put a non-data snapshot at the tip whose completion 
time is later than the
+   * commits it protects, which breaks the rollback path. {@link
+   * org.apache.xtable.timeline.IcebergActiveTimeline} takes savepoint 
instants from the Hudi
+   * timeline directly instead.
+   */
+  @Override
+  public void savepoint(
+      HoodieInstant instant,
+      HoodieEngineContext engineContext,
+      HoodieTableMetaClient metaClient,
+      FileSystemViewManager viewManager) {}
+
+  /**
+   * The per-instant rollbacks a restore performs already moved the Iceberg 
table back, and the
+   * restore instant itself changes no data files.
+   */
+  @Override
+  public void restore(
+      HoodieInstant restoreCompletedInstant,
+      HoodieEngineContext engineContext,
+      HoodieTableMetaClient metaClient,
+      FileSystemViewManager viewManager) {}
+
+  @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(ImmutableMap.of(target, tableSyncMetadata), 
changes);
+    } catch (Exception e) {
+      throw new UpdateException("Failed to update iceberg metadata", e);
+    }
+  }
+
+  private void archiveInstants(
+      HoodieTableMetaClient metaClient,
+      InternalTable internalTable,
+      List<HoodieInstant> 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))

Review Comment:
   Question about partition types rather than a problem I can demonstrate. 
`String.format("%s:VALUE", p)` maps every Hudi partition field to the `VALUE` 
type.
   
   For a table partitioned on a date-formatted string column, does that lose 
the date interpretation `PathBasedPartitionSpecExtractor` would otherwise 
apply, and produce a string-typed Iceberg partition field? If non-`VALUE` types 
are out of scope for now, would it be worth a line in the limitations list and 
a `// TODO` here, so the code does not read as covering them?
   
   Minor: a named constant for `"VALUE"` might read better than the inline 
format string, and I think the extractor already has one.



##########
xtable-core/src/main/java/org/apache/xtable/hudi/HudiTableExtractor.java:
##########
@@ -88,9 +95,62 @@ public InternalTable table(HoodieTableMetaClient metaClient, 
HoodieInstant commi
         .readSchema(canonicalSchema)
         .latestMetadataPath(metaClient.getMetaPath().toString())
         
.latestCommitTime(HudiInstantUtils.parseFromInstantTime(commit.requestedTime()))
+        .latestTableOperationId(generateTableOperationId(commit))
         .build();
   }
 
+  public InternalTable table(
+      HoodieTableMetaClient metaClient,
+      HoodieCommitMetadata commitMetadata,
+      HoodieInstant completedInstant) {
+    InternalSchema canonicalSchema = getCanonicalSchema(commitMetadata);
+    List<InternalPartitionField> partitionFields = 
partitionSpecExtractor.spec(canonicalSchema);
+    List<InternalField> 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())
+        .latestCommitTime(

Review Comment:
   @yihua I think should we make a new class `HudiTableExtractorV9` for 
pluggable table format? The latestCommitTime used will be different for v6 and 
v9..the original PR probably just added it here for overloading and avoiding 
new classes.



##########
xtable-hudi-support/xtable-iceberg-pluggable-tf/pom.xml:
##########
@@ -0,0 +1,205 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0";
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.xtable</groupId>
+        <artifactId>xtable-hudi-support</artifactId>
+        <version>0.5.0-SNAPSHOT</version>
+    </parent>
+
+    
<artifactId>xtable-iceberg-pluggable-tf_${scala.binary.version}</artifactId>
+    <name>XTable Project Iceberg Pluggable Table Format</name>
+
+    <dependencies>
+
+        <dependency>
+            <groupId>org.apache.xtable</groupId>
+            <artifactId>xtable-core_${scala.binary.version}</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <!-- Logging API -->
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.hudi</groupId>
+            <artifactId>hudi-client-common</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hudi</groupId>
+            <artifactId>hudi-sync-common</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hadoop</groupId>
+            <artifactId>hadoop-common</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Avro -->
+        <dependency>
+            <groupId>org.apache.avro</groupId>
+            <artifactId>avro</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Jackson -->
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>

Review Comment:
   Two dependencies the new code compiles against do not seem to be declared in 
this module, so I think both resolve transitively through `xtable-core`:
   
   - `jackson-datatype-jsr310`, since `JavaTimeModule` is imported in four of 
the new classes, while `jackson-core` and `jackson-databind` are declared right 
here.
   - `guava`, since `IcebergTableFormat` imports 
`com.google.common.collect.ImmutableMap`.
   
   Nothing breaks today, so this is about not depending on `xtable-core`'s 
scopes staying as they are. Would declaring jsr310 next to the other two be 
reasonable? For guava, `Collections.singletonMap(target, tableSyncMetadata)` at 
that one call site would avoid the dependency altogether, though I appreciate 
that is a matter of taste.
   
   A separate question while I am in this file: with no shade or assembly 
plugin here, and `iceberg-core` at compile scope while the rest is `provided`, 
how do you picture a user putting this on a Hudi writer's classpath? I ask 
mainly because it will come up in the release discussion. If assembling it 
themselves is the plan for now, a note in the description would help; if a 
bundle is intended to follow, naming it as a follow-up would be enough.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to