This is an automated email from the ASF dual-hosted git repository.

vinishjail97 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-xtable.git


The following commit(s) were added to refs/heads/main by this push:
     new 788b9ae7 [834] Build the column-stats index for existing Hudi targets 
(#961)
788b9ae7 is described below

commit 788b9ae7b5e921f2722c6a37845e40ce6c0f06d4
Author: Vinish Reddy <[email protected]>
AuthorDate: Wed Oct 7 06:53:51 2026 +0530

    [834] Build the column-stats index for existing Hudi targets (#961)
    
    - Require a snapshot sync when a Hudi target has commits but no 
column-stats index
    - Register every snapshot file again in that sync, so Hudi indexes all 
files from the
      commit's write stats; Hudi's own bootstrap skips externally created files
    - Never replace a fileId that the same commit writes
    - Find older file slices of live file groups when marking files as cleaned
---
 .../apache/xtable/spi/sync/ConversionTarget.java   |  12 ++
 .../xtable/conversion/ConversionController.java    |   6 +
 .../xtable/hudi/BaseFileUpdatesExtractor.java      |  37 +++-
 .../apache/xtable/hudi/HudiConversionTarget.java   |  68 ++++--
 .../conversion/TestConversionController.java       |  56 +++++
 .../apache/xtable/hudi/ITHudiConversionTarget.java | 227 +++++++++++++++++++++
 6 files changed, 380 insertions(+), 26 deletions(-)

diff --git 
a/xtable-api/src/main/java/org/apache/xtable/spi/sync/ConversionTarget.java 
b/xtable-api/src/main/java/org/apache/xtable/spi/sync/ConversionTarget.java
index 6f8f9395..fda3e7b5 100644
--- a/xtable-api/src/main/java/org/apache/xtable/spi/sync/ConversionTarget.java
+++ b/xtable-api/src/main/java/org/apache/xtable/spi/sync/ConversionTarget.java
@@ -87,6 +87,18 @@ public interface ConversionTarget {
   /** Returns the xtable metadata persisted in the target */
   Optional<TableSyncMetadata> getTableMetadata();
 
+  /**
+   * Checks if an incremental sync can bring the target table up to date. The 
source decides this
+   * with {@link 
org.apache.xtable.spi.extractor.ConversionSource#isIncrementalSyncSafeFrom}; 
this
+   * method lets the target also require a snapshot sync, for example when the 
target is missing
+   * metadata that only the full set of source files can build.
+   *
+   * @return true if incremental sync is safe for the target table, false 
otherwise
+   */
+  default boolean isIncrementalSyncSafe() {
+    return true;
+  }
+
   /** Returns the TableFormat name the client syncs to */
   String getTableFormat();
 
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionController.java
 
b/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionController.java
index 73bc531a..8bbc147d 100644
--- 
a/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionController.java
+++ 
b/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionController.java
@@ -263,6 +263,12 @@ public class ConversionController {
     return conversionTargetByFormat.entrySet().stream()
         .filter(
             entry -> {
+              if (!entry.getValue().isIncrementalSyncSafe()) {
+                log.info(
+                    "Incremental sync is not safe for target {}. Falling back 
to snapshot sync.",
+                    entry.getKey());
+                return false;
+              }
               Optional<Instant> lastSyncInstant =
                   lastSyncMetadataByFormat
                       .get(entry.getKey())
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/hudi/BaseFileUpdatesExtractor.java
 
b/xtable-core/src/main/java/org/apache/xtable/hudi/BaseFileUpdatesExtractor.java
index 349b8daa..2bd1134b 100644
--- 
a/xtable-core/src/main/java/org/apache/xtable/hudi/BaseFileUpdatesExtractor.java
+++ 
b/xtable-core/src/main/java/org/apache/xtable/hudi/BaseFileUpdatesExtractor.java
@@ -57,6 +57,7 @@ import org.apache.hudi.common.util.ExternalFilePathUtil;
 import org.apache.hudi.hadoop.fs.CachingPath;
 import org.apache.hudi.metadata.HoodieIndexVersion;
 import org.apache.hudi.metadata.HoodieTableMetadata;
+import org.apache.hudi.metadata.MetadataPartitionType;
 import org.apache.hudi.stats.HoodieColumnRangeMetadata;
 import org.apache.hudi.stats.ValueMetadata;
 import org.apache.hudi.stats.XTableValueMetadata;
@@ -141,6 +142,14 @@ public class BaseFileUpdatesExtractor {
       HoodieTableMetaClient metaClient,
       HoodieMetadataConfig metadataConfig) {
     boolean isTableInitialized = metaClient.isTimelineNonEmpty();
+    // Hudi builds the column-stats index for existing files from the metadata 
table's file listing,
+    // which skips externally created files. When the index is missing, 
register every snapshot file
+    // again, so the index gets the stats of all files from this commit's 
write stats.
+    boolean reregisterExistingFiles =
+        isTableInitialized
+            && !metaClient
+                .getTableConfig()
+                
.isMetadataPartitionAvailable(MetadataPartitionType.COLUMN_STATS);
     // Track the partitions that are not present in the snapshot, so the files 
for those partitions
     // can be dropped
     HoodieIndexVersion indexVersion =
@@ -169,18 +178,15 @@ public class BaseFileUpdatesExtractor {
                           : Collections.emptyList();
                   Set<String> existingPaths =
                       
baseFiles.stream().map(HoodieBaseFile::getPath).collect(Collectors.toSet());
-                  // Mark fileIds for removal if the file paths are no longer 
present in the
-                  // snapshot
-                  List<String> fileIdsToRemove =
-                      baseFiles.stream()
-                          .filter(baseFile -> 
!physicalPathToFile.containsKey(baseFile.getPath()))
-                          .map(HoodieBaseFile::getFileId)
-                          .collect(Collectors.toList());
                   // for any entries in the map that are not in the set of 
existing paths, create a
-                  // write status to add them to the Hudi table
+                  // write status to add them to the Hudi table; an existing 
file gets a new file
+                  // slice in its file group when it is registered again
                   List<WriteStatus> writeStatuses =
                       physicalPathToFile.entrySet().stream()
-                          .filter(entry -> 
!existingPaths.contains(entry.getKey()))
+                          .filter(
+                              entry ->
+                                  reregisterExistingFiles
+                                      || 
!existingPaths.contains(entry.getKey()))
                           .map(Map.Entry::getValue)
                           .map(
                               snapshotFile ->
@@ -191,6 +197,19 @@ public class BaseFileUpdatesExtractor {
                                       Optional.of(partitionPath),
                                       indexVersion))
                           .collect(Collectors.toList());
+                  // Mark fileIds for removal if the file paths are no longer 
present in the
+                  // snapshot. A fileId that this commit writes is never 
replaced, because Hudi
+                  // hides every file slice of a replaced file group.
+                  Set<String> writtenFileIds =
+                      writeStatuses.stream()
+                          .map(WriteStatus::getFileId)
+                          .collect(Collectors.toSet());
+                  List<String> fileIdsToRemove =
+                      baseFiles.stream()
+                          .filter(baseFile -> 
!physicalPathToFile.containsKey(baseFile.getPath()))
+                          .map(HoodieBaseFile::getFileId)
+                          .filter(fileId -> !writtenFileIds.contains(fileId))
+                          .collect(Collectors.toList());
                   return ReplaceMetadata.of(
                       fileIdsToRemove.isEmpty()
                           ? Collections.emptyMap()
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiConversionTarget.java 
b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiConversionTarget.java
index 6523b519..a463062c 100644
--- a/xtable-core/src/main/java/org/apache/xtable/hudi/HudiConversionTarget.java
+++ b/xtable-core/src/main/java/org/apache/xtable/hudi/HudiConversionTarget.java
@@ -33,7 +33,6 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.Properties;
 import java.util.Set;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -81,6 +80,7 @@ import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.hadoop.fs.CachingPath;
 import org.apache.hudi.metadata.HoodieIndexVersion;
 import org.apache.hudi.metadata.HoodieTableMetadataWriter;
+import org.apache.hudi.metadata.MetadataPartitionType;
 import org.apache.hudi.table.HoodieJavaTable;
 import org.apache.hudi.table.HoodieTable;
 import org.apache.hudi.table.action.clean.CleanPlanner;
@@ -403,6 +403,30 @@ public class HudiConversionTarget implements 
ConversionTarget {
                 .flatMap(instant -> getMetadata(instant, client)));
   }
 
+  /**
+   * XTable 0.4.0 did not build the column-stats index for partitioned tables. 
Hudi cannot build it
+   * later for the externally created files, so a snapshot sync registers 
every file again with its
+   * stats.
+   */
+  @Override
+  public boolean isIncrementalSyncSafe() {
+    return metaClient
+        .map(
+            client -> {
+              if (!client.isTimelineNonEmpty()
+                  || client
+                      .getTableConfig()
+                      
.isMetadataPartitionAvailable(MetadataPartitionType.COLUMN_STATS)) {
+                return true;
+              }
+              log.info(
+                  "Hudi target table at {} has no column-stats index, so a 
snapshot sync builds it.",
+                  tableDataPath);
+              return false;
+            })
+        .orElse(true);
+  }
+
   @Override
   public String getTableFormat() {
     return TableFormat.HUDI;
@@ -590,23 +614,33 @@ public class HudiConversionTarget implements 
ConversionTarget {
                       deletePathsForPartition -> {
                         String partition = deletePathsForPartition.getKey();
                         // we need to manipulate the path to properly clean 
from the metadata table,
-                        // so we map the file path to the base file
-                        Map<String, HoodieBaseFile> baseFilesByPath =
-                            fsView
-                                .getAllReplacedFileGroups(partition)
-                                .flatMap(HoodieFileGroup::getAllBaseFiles)
-                                .collect(
-                                    Collectors.toMap(HoodieBaseFile::getPath, 
Function.identity()));
+                        // so we map the file path to the base files that can 
be cleaned. A file
+                        // registered again by a snapshot sync has an older 
file slice in a live
+                        // file group with the same path, so the latest slice 
of a live file group
+                        // is never a candidate.
+                        Map<String, List<HoodieBaseFile>> 
cleanableBaseFilesByPath =
+                            Stream.concat(
+                                    fsView
+                                        .getAllFileGroups(partition)
+                                        .flatMap(fileGroup -> 
fileGroup.getAllBaseFiles().skip(1)),
+                                    fsView
+                                        .getAllReplacedFileGroups(partition)
+                                        
.flatMap(HoodieFileGroup::getAllBaseFiles))
+                                
.collect(Collectors.groupingBy(HoodieBaseFile::getPath));
                         return 
deletePathsForPartition.getValue().getValue().stream()
+                            .map(cleanFileInfo -> cleanFileInfo.getFilePath())
+                            .distinct()
+                            .flatMap(
+                                path ->
+                                    cleanableBaseFilesByPath
+                                        .getOrDefault(path, 
Collections.emptyList())
+                                        .stream())
                             .map(
-                                cleanFileInfo -> {
-                                  HoodieBaseFile baseFile =
-                                      
baseFilesByPath.get(cleanFileInfo.getFilePath());
-                                  return new HoodieCleanFileInfo(
-                                      
ExternalFilePathUtil.appendCommitTimeAndExternalFileMarker(
-                                          baseFile.getFileName(), 
baseFile.getCommitTime()),
-                                      false);
-                                })
+                                baseFile ->
+                                    new HoodieCleanFileInfo(
+                                        
ExternalFilePathUtil.appendCommitTimeAndExternalFileMarker(
+                                            baseFile.getFileName(), 
baseFile.getCommitTime()),
+                                        false))
                             .collect(Collectors.toList());
                       }));
       // there is nothing to clean, so exit early
@@ -656,7 +690,7 @@ public class HudiConversionTarget implements 
ConversionTarget {
                       String partitionPath = entry.getKey();
                       List<String> deletePaths =
                           entry.getValue().stream()
-                              .map(HoodieCleanFileInfo::getFilePath)
+                              .map(cleanFileInfo -> 
cleanFileInfo.getFilePath())
                               .collect(Collectors.toList());
                       return HoodieCleanStat.builder()
                           .withPolicy(HoodieCleaningPolicy.KEEP_LATEST_COMMITS)
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionController.java
 
b/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionController.java
index 6569c4e8..3d7c8808 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionController.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionController.java
@@ -96,6 +96,8 @@ public class TestConversionController {
   void setupTargetFormats() {
     when(mockConversionTarget1.getTableFormat()).thenReturn(ICEBERG);
     when(mockConversionTarget2.getTableFormat()).thenReturn(DELTA);
+    when(mockConversionTarget1.isIncrementalSyncSafe()).thenReturn(true);
+    when(mockConversionTarget2.isIncrementalSyncSafe()).thenReturn(true);
   }
 
   @Test
@@ -285,6 +287,60 @@ public class TestConversionController {
     assertEquals(syncResults, result);
   }
 
+  @Test
+  void testIncrementalSyncFallBackToSnapshotWhenTargetIsNotSafe() {
+    SyncMode syncMode = SyncMode.INCREMENTAL;
+    InternalTable internalTable = getInternalTable();
+    Instant instantBeforeHour = Instant.now().minus(Duration.ofHours(1));
+    InternalSnapshot internalSnapshot = buildSnapshot(internalTable, "v1", 
"0");
+    SyncResult syncResult = buildSyncResult(syncMode, instantBeforeHour);
+    Map<String, SyncResult> syncResults = new HashMap<>();
+    syncResults.put(TableFormat.ICEBERG, syncResult);
+    syncResults.put(TableFormat.DELTA, syncResult);
+    ConversionConfig conversionConfig =
+        getTableSyncConfig(Arrays.asList(TableFormat.ICEBERG, 
TableFormat.DELTA), syncMode);
+    when(mockConversionSourceProvider.getConversionSourceInstance(
+            conversionConfig.getSourceTable()))
+        .thenReturn(mockConversionSource);
+    when(mockConversionTargetFactory.createForFormat(
+            conversionConfig.getTargetTables().get(0), mockConf))
+        .thenReturn(mockConversionTarget1);
+    when(mockConversionTargetFactory.createForFormat(
+            conversionConfig.getTargetTables().get(1), mockConf))
+        .thenReturn(mockConversionTarget2);
+
+    Instant instantAsOfNow = Instant.now();
+    Instant instantAt5 = getInstantAtLastNMinutes(instantAsOfNow, 5);
+    
when(mockConversionSource.isIncrementalSyncSafeFrom(eq(instantAt5))).thenReturn(true);
+    // The source allows an incremental sync, but both targets require a 
snapshot sync.
+    when(mockConversionTarget1.isIncrementalSyncSafe()).thenReturn(false);
+    when(mockConversionTarget2.isIncrementalSyncSafe()).thenReturn(false);
+
+    // Both Iceberg and Delta last synced at instantAt5 and have no pending 
instants.
+    when(mockConversionTarget1.getTableMetadata())
+        .thenReturn(
+            Optional.of(TableSyncMetadata.of(instantAt5, 
Collections.emptyList(), "TEST", "0")));
+    when(mockConversionTarget2.getTableMetadata())
+        .thenReturn(
+            Optional.of(TableSyncMetadata.of(instantAt5, 
Collections.emptyList(), "TEST", "0")));
+
+    
when(mockConversionSource.getCurrentSnapshot()).thenReturn(internalSnapshot);
+    when(tableFormatSync.syncSnapshot(
+            argThat(containsAll(Arrays.asList(mockConversionTarget1, 
mockConversionTarget2))),
+            eq(internalSnapshot)))
+        .thenReturn(syncResults);
+    ConversionController conversionController =
+        new ConversionController(
+            mockConf,
+            mockConversionTargetFactory,
+            mockCatalogConversionFactory,
+            tableFormatSync,
+            catalogSync);
+    Map<String, SyncResult> result =
+        conversionController.sync(conversionConfig, 
mockConversionSourceProvider);
+    assertEquals(syncResults, result);
+  }
+
   @Test
   void testIncrementalSyncFallbackToSnapshotForOnlySingleFormat() {
     SyncMode syncMode = SyncMode.INCREMENTAL;
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/hudi/ITHudiConversionTarget.java 
b/xtable-core/src/test/java/org/apache/xtable/hudi/ITHudiConversionTarget.java
index 64d5e9ca..0707f435 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/hudi/ITHudiConversionTarget.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/hudi/ITHudiConversionTarget.java
@@ -27,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.nio.file.Path;
 import java.time.Duration;
 import java.time.Instant;
@@ -46,7 +48,12 @@ import lombok.SneakyThrows;
 
 import org.apache.avro.Schema;
 import org.apache.avro.SchemaBuilder;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
 import org.apache.hadoop.conf.Configuration;
+import org.apache.parquet.avro.AvroParquetWriter;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.util.HadoopOutputFile;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.io.TempDir;
@@ -61,6 +68,7 @@ import org.apache.hudi.avro.model.StringWrapper;
 import org.apache.hudi.client.HoodieJavaWriteClient;
 import org.apache.hudi.client.WriteStatus;
 import org.apache.hudi.client.common.HoodieJavaEngineContext;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
 import org.apache.hudi.common.config.TypedProperties;
 import org.apache.hudi.common.engine.HoodieEngineContext;
 import org.apache.hudi.common.model.HoodieBaseFile;
@@ -80,6 +88,7 @@ import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.collection.Pair;
 import org.apache.hudi.config.HoodieWriteConfig;
 import org.apache.hudi.metadata.HoodieBackedTableMetadata;
+import org.apache.hudi.metadata.MetadataPartitionType;
 import org.apache.hudi.storage.StorageConfiguration;
 
 import org.apache.xtable.conversion.TargetTable;
@@ -246,6 +255,224 @@ public class ITHudiConversionTarget {
     assertEquals(tableVersion, metaClient.getTableConfig().getTableVersion());
   }
 
+  /**
+   * XTable 0.4.0 did not build the column-stats index for partitioned 
targets, and Hudi cannot
+   * build it later for externally created files. The target must require one 
snapshot sync, which
+   * registers every file again with its stats, and a later clean must keep 
those stats.
+   */
+  @ParameterizedTest
+  @EnumSource(
+      value = HoodieTableVersion.class,
+      names = {"SIX", "NINE"})
+  void 
snapshotSyncBuildsColStatsForExistingTableWithoutColStats(HoodieTableVersion 
tableVersion) {
+    String partitionPath = "partition_path";
+    String existingFileName = "existing_file_1.parquet";
+    String existingFilePath = getFilePath(partitionPath, existingFileName);
+    HoodieTableMetaClient setupMetaClient =
+        initTableAndGetMetaClient(tableBasePath, PARTITION_FIELD_NAME, 
tableVersion);
+    Schema avroSchema =
+        SchemaBuilder.record(TEST_SCHEMA_NAME)
+            .fields()
+            .requiredString(KEY_FIELD_NAME)
+            .requiredString(PARTITION_FIELD_NAME)
+            .requiredString(OTHER_FIELD_NAME)
+            .endRecord();
+    writeParquetFile(tableBasePath + "/" + existingFilePath, avroSchema);
+    // register the file the way XTable 0.4.0 did, without the column-stats 
index
+    HoodieWriteConfig baseWriteConfig = getHoodieWriteConfig(setupMetaClient, 
avroSchema);
+    HoodieWriteConfig writeConfigWithoutColStats =
+        HoodieWriteConfig.newBuilder()
+            .withProperties(baseWriteConfig.getProps())
+            .withMetadataConfig(
+                HoodieMetadataConfig.newBuilder()
+                    
.fromProperties(baseWriteConfig.getMetadataConfig().getProps())
+                    .withMetadataIndexColumnStats(false)
+                    .build())
+            .build();
+    String initialInstant =
+        HudiInstantUtils.convertInstantToCommit(Instant.now().minus(30, 
ChronoUnit.HOURS));
+    try (HoodieJavaWriteClient<?> writeClient =
+        new HoodieJavaWriteClient<>(CONTEXT, writeConfigWithoutColStats)) {
+      setupMetaClient
+          .getActiveTimeline()
+          .createRequestedCommitWithReplaceMetadata(
+              initialInstant, HoodieTimeline.REPLACE_COMMIT_ACTION);
+      setupMetaClient
+          .getActiveTimeline()
+          .transitionReplaceRequestedToInflight(
+              new HoodieInstant(
+                  HoodieInstant.State.REQUESTED,
+                  HoodieTimeline.REPLACE_COMMIT_ACTION,
+                  initialInstant,
+                  InstantComparatorV2.REQUESTED_TIME_BASED_COMPARATOR),
+              Option.empty());
+      writeClient.setOperationType(WriteOperationType.INSERT);
+      writeClient.commit(
+          initialInstant,
+          Collections.singletonList(
+              createWriteStatus(
+                  existingFileName, partitionPath, initialInstant, 2, 100, 
Collections.emptyMap())),
+          Option.empty(),
+          HoodieTimeline.REPLACE_COMMIT_ACTION,
+          Collections.emptyMap());
+    }
+    assertFalse(
+        HoodieTableMetaClient.reload(setupMetaClient)
+            .getTableConfig()
+            .isMetadataPartitionAvailable(MetadataPartitionType.COLUMN_STATS));
+    assertFalse(getTargetClient(tableVersion).isIncrementalSyncSafe());
+
+    // the snapshot sync registers the existing file again and adds a new file
+    String fileName = "file_1.parquet";
+    String filePath = getFilePath(partitionPath, fileName);
+    InternalTable state = getState(Instant.now().minus(20, ChronoUnit.HOURS), 
true);
+    HudiConversionTarget targetClient = getTargetClient(tableVersion);
+    targetClient.beginSync(state);
+    targetClient.syncFilesForSnapshot(
+        Collections.singletonList(
+            PartitionFileGroup.builder()
+                .files(
+                    Arrays.asList(
+                        getExistingTestFile(partitionPath, existingFileName),
+                        getTestFile(partitionPath, fileName)))
+                .partitionValues(
+                    Collections.singletonList(
+                        PartitionValue.builder()
+                            .partitionField(PARTITION_FIELD)
+                            .range(Range.scalar("partitionPath"))
+                            .build()))
+                .build()));
+    targetClient.syncSchema(SCHEMA);
+    targetClient.syncMetadata(
+        TableSyncMetadata.of(state.getLatestCommitTime(), 
Collections.emptyList(), "TEST", "0"));
+    targetClient.completeSync();
+
+    HoodieTableMetaClient metaClient =
+        
HoodieTableMetaClient.builder().setConf(CONFIGURATION).setBasePath(tableBasePath).build();
+    assertTrue(
+        metaClient
+            .getTableConfig()
+            .isMetadataPartitionAvailable(MetadataPartitionType.COLUMN_STATS));
+    assertTrue(getTargetClient(tableVersion).isIncrementalSyncSafe());
+    // the existing file keeps its physical path
+    List<Pair<String, String>> expectedFiles =
+        Arrays.asList(Pair.of(existingFileName, existingFilePath), 
Pair.of(fileName, filePath));
+    assertFileGroupCorrectness(metaClient, partitionPath, expectedFiles);
+    assertColStatsForExistingAndNewFile(
+        metaClient, baseWriteConfig, partitionPath, existingFileName, 
fileName);
+
+    // the cleaner keeps the commits of the last 4 hours, so a commit 1 hour 
ago makes the next
+    // commit clean the older file slice of the existing file
+    incrementalSync(
+        targetClient,
+        Collections.singletonList(getTestFile(partitionPath, 
"file_2.parquet")),
+        Collections.emptyList(),
+        Instant.now().minus(1, ChronoUnit.HOURS),
+        "1",
+        true);
+    incrementalSync(
+        targetClient,
+        Collections.singletonList(getTestFile(partitionPath, 
"file_3.parquet")),
+        Collections.emptyList(),
+        Instant.now(),
+        "2",
+        true);
+    metaClient = HoodieTableMetaClient.reload(metaClient);
+    assertTrue(
+        metaClient
+                .getActiveTimeline()
+                .getCleanerTimeline()
+                .filterCompletedInstants()
+                .countInstants()
+            > 0);
+    assertColStatsForExistingAndNewFile(
+        metaClient, baseWriteConfig, partitionPath, existingFileName, 
fileName);
+    try (HoodieTableFileSystemView fsView =
+        new HoodieTableFileSystemView(
+            new HoodieBackedTableMetadata(
+                CONTEXT,
+                metaClient.getStorage(),
+                baseWriteConfig.getMetadataConfig(),
+                tableBasePath,
+                true),
+            metaClient,
+            metaClient.getActiveTimeline())) {
+      // the clean removed the older file slice and kept the one from the 
snapshot sync
+      HoodieFileGroup existingFileGroup =
+          fsView
+              .getAllFileGroups(partitionPath)
+              .filter(fileGroup -> 
fileGroup.getFileGroupId().getFileId().equals(existingFileName))
+              .findFirst()
+              .get();
+      List<HoodieBaseFile> existingBaseFiles =
+          existingFileGroup.getAllBaseFiles().collect(Collectors.toList());
+      assertEquals(1, existingBaseFiles.size());
+      assertEquals(
+          HudiInstantUtils.convertInstantToCommit(state.getLatestCommitTime()),
+          existingBaseFiles.get(0).getCommitTime());
+      assertEquals(tableBasePath + "/" + existingFilePath, 
existingBaseFiles.get(0).getPath());
+    }
+  }
+
+  private void assertColStatsForExistingAndNewFile(
+      HoodieTableMetaClient metaClient,
+      HoodieWriteConfig writeConfig,
+      String partitionPath,
+      String existingFileName,
+      String fileName) {
+    try (HoodieBackedTableMetadata hoodieBackedTableMetadata =
+        new HoodieBackedTableMetadata(
+            CONTEXT,
+            metaClient.getStorage(),
+            writeConfig.getMetadataConfig(),
+            tableBasePath,
+            true)) {
+      assertColStats(hoodieBackedTableMetadata, partitionPath, fileName);
+      assertColStatsForField(
+          hoodieBackedTableMetadata,
+          partitionPath,
+          existingFileName,
+          KEY_FIELD_NAME,
+          "id3",
+          "id4",
+          2,
+          0,
+          5);
+    }
+  }
+
+  private InternalDataFile getExistingTestFile(String partitionPath, String 
fileName) {
+    return getTestFile(partitionPath, fileName).toBuilder()
+        .columnStats(
+            Collections.singletonList(
+                ColumnStat.builder()
+                    .field(SCHEMA.getFields().get(0))
+                    .range(Range.vector("id3", "id4"))
+                    .numNulls(0)
+                    .numValues(2)
+                    .totalSize(5)
+                    .build()))
+        .build();
+  }
+
+  private static void writeParquetFile(String path, Schema avroSchema) {
+    try (ParquetWriter<GenericRecord> writer =
+        AvroParquetWriter.<GenericRecord>builder(
+                HadoopOutputFile.fromPath(new org.apache.hadoop.fs.Path(path), 
new Configuration()))
+            .withSchema(avroSchema)
+            .build()) {
+      for (String id : Arrays.asList("id3", "id4")) {
+        GenericRecord record = new GenericData.Record(avroSchema);
+        record.put(KEY_FIELD_NAME, id);
+        record.put(PARTITION_FIELD_NAME, "c");
+        record.put(OTHER_FIELD_NAME, "content");
+        writer.write(record);
+      }
+    } catch (IOException ex) {
+      throw new UncheckedIOException(ex);
+    }
+  }
+
   @ParameterizedTest
   @MethodSource("partitionedAndVersion")
   void syncForNewTable(boolean partitioned, HoodieTableVersion tableVersion) {

Reply via email to