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 754fd270 Parquet Incremental Sync (#768)
754fd270 is described below

commit 754fd270e6f0f74c089f6c64f5c386a6d67b05f1
Author: Selim S. <[email protected]>
AuthorDate: Fri Jun 5 23:21:29 2026 +0200

    Parquet Incremental Sync (#768)
    
    * given a parquet file return data from a certain modification time
    
    * create the path based on the partition then inject the file to append 
into the parquet table
    
    * Handle case of path construction with file partitioned over many fields, 
interfacing with ConversionSource
    
    * test append Parquet file into table init
    
    * add function to test schema equivalence before appending
    
    * construct path to inject to based on partitions
    
    * fix imports
    
    * refactoring (lombok, logs, javadocs and function and approach commenting)
    
    * use appendFile to append a file into a table while tracking the appends 
using the FileStatus' modifTime attribute
    
    * find the files that satisfy to the time condition
    
    * treat appends as separate files to add in the target partition folder
    
    * update approach: selective block compaction
    
    * update approach: added a basic test to check data selection using 
modificationTime selector
    
    * fix append based on partition value
    
    * fix test with basic example where partitions are not considered
    
    * fix test with basic example where partitions are not considered2
    
    * fix test with basic example where partitions are not considered3
    
    * test with time of last append is now
    
    * test appendFile with Parquet: TODO test with multiple partitions 1) 
append and 2) filter for sync
    
    * merge recursively one partition files
    
    * fix paths for files to append
    
    * fix bug of appending file path
    
    * fix bug of schema
    
    * make sure returned files are non empty when iterating in the partitions
    
    * make sure returned files are non empty when iterating in the partitions 2
    
    * discard empty files when appending
    
    * discard empty files when appending
    
    * one spark session for both tests
    
    * bug fix
    
    * bug fix
    
    * cleanups + TODO: partitions match and merge
    
    * added test for many partitions, TODO integrate functions into 
ParquetConversionSource
    
    * selecting data bug fix, TODO integrate functions into 
ParquetConversionSource
    
    * run all tests, TODO integrate functions into ParquetConversionSource
    
    * run all tests, TODO integrate functions into ParquetConversionSource
    
    * spotless:apply, TODO integrate functions into ParquetConversionSource
    
    * bug fixes, TODO integrate functions into ParquetConversionSource
    
    * bug fixes, TODO integrate functions into ParquetConversionSource
    
    * handle lightweight metadata in the conversionSource
    
    * fix CI
    
    * fix CI, refactoring and isIncrementalSyncSafeFrom implementation
    
    * added test
    
    * added test: init conversionSource instance
    
    * added test: init conversionSource instance: fix error
    
    * added test: fix error
    
    * more tests + bug fixes and reformatting
    
    * CI fix
    
    * CI fix
    
    * CI fix
    
    * CI fix
    
    * CI fix
    
    * CI fix
    
    * merge ready fixes & tests + refactoring & reformatting
    
    * optimization: read metadata once for target file, read parquet files 
once, reformatting
    
    * Lazily fetch info, move state into ParquetDataManager
    
    * add TestParquetDataManager, simplify data manager methods
    
    * make IT parameterized, add incremental sync
    
    * fix schema extraction to fix snapshot sync
    
    * add optimizations
    
    * fix for ParquetDataManager Test (mock())
    
    * before resycing delete the first synced files metadata
    
    * in order to append parquet files, perform union then spark overwrite (not 
append)
    
    * solving directory issue when overwriting data using spark
    
    * solving directory issue when overwriting data using spark2
    
    * solving directory issue when overwriting data using spark3
    
    * solving directory issue when overwriting data using spark4
    
    * solving directory issue when overwriting data using spark5
    
    * solving directory issue when overwriting data using spark6
    
    * solving directory issue when overwriting data using spark7
    
    * revert writeData changes
    
    * solving directory issue when overwriting data using spark8
    
    * solving directory issue when overwriting data using spark9
    
    * solving directory issue when overwriting data using spark9
    
    * revert changes
    
    * testing without cleaning metadata for the second sync
    
    * testing without cleaning metadata for the second sync
    
    * testing without cleaning metadata for the second sync
    
    * testing without cleaning metadata for the second sync
    
    * testing without cleaning metadata for the second sync
    
    * revert changes
    
    * revert changes
    
    * revert changes
    
    * use no tempDir
    
    * revert changes
    
    * use union() to append data for the second sync()
    
    * use union() to append data for the second sync()
    
    * use union() to append data for the second sync()
    
    * use union() to append data for the second sync()
    
    * use union() to append data for the second sync() reverted
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * fix second sync error
    
    * remove old blocking tests
    
    * spotless:apply and more tests
    
    * spotless:apply and test fix
    
    * spotless:apply and test fix
    
    * spotless:apply and test fix
    
    * test fix
    
    * spotless
    
    * spotless imports
    
    * spotless imports
    
    * add syncMode Incr
    
    * revert changes
    
    * add syncMode Incr
    
    * spotless:apply
    
    * update naming
    
    * minimize diff with main
    
    * added test for tableChangeAddedFiles and changed path construction of 
ParquetDataManager and adjusted operator for mostRecentFile + spotless
    
    * added test for tableChangeAddedFiles and changed path construction of 
ParquetDataManager and adjusted operator for mostRecentFile + spotless + import 
error fixed
    
    * tests fixed
    
    * tests fixed
    
    * tests fixed
    
    * log for actual changes
    
    * log for actual changes
    
    * tests fixed
    
    * spotless
    
    * removed comment
    
    * spotless
    
    * address PR comments
    
    * fix path
    
    * add safety check around handling of out of sync targets with parquet 
source
    
    * Address review feedback: fetch parquet listing once; drop unused 
ParquetDataManager methods
    
    - ParquetConversionSource: materialize the file listing once per operation 
in
      getTableChangeForCommit and getCurrentSnapshot instead of re-listing the
      filesystem 2-3 times (addresses the 'fetch the list once' review comment).
    - ParquetDataManager: remove getParquetDataFileAt and
      getParquetFilesMetadataAfterTime; both had no production caller (only 
tests).
    - TestParquetDataManager: drop tests for the removed methods and repoint the
      real-filesystem tests to getCurrentFilesInfo.
    
    * Derive source identifier from the same listing as the table change
    
    getTableChangeForCommit computed filesAdded and the table from one
    materialized listing but derived the committed sourceIdentifier from a
    separate parquetDataManager.getMostRecentParquetFile() listing. A file
    landing between the two listings could advance the committed identifier
    past a file that was never included in filesAdded, permanently skipping
    it on the next incremental sync.
    
    Use the most recent file from the same snapshot for both the table and
    the source identifier. Add an assertion in ITParquetConversionSource
    that the committed identifier matches that snapshot's latest mod time.
    
    ---------
    
    Co-authored-by: Selim Soufargi <[email protected]~>
    Co-authored-by: Timothy Brown <[email protected]>
    Co-authored-by: Tim Brown <[email protected]>
    Co-authored-by: Vinish Reddy <[email protected]>
---
 .../xtable/model/InstantsForIncrementalSync.java   |   9 +
 .../xtable/conversion/ConversionController.java    |  20 +-
 .../xtable/parquet/ParquetConversionSource.java    |  76 ++++-
 .../parquet/ParquetConversionSourceProvider.java   |   3 +
 .../apache/xtable/parquet/ParquetDataManager.java  |  96 +++++++
 .../org/apache/xtable/parquet/ParquetFileInfo.java |  51 ++++
 .../conversion/TestConversionController.java       |  31 ++-
 .../xtable/parquet/ITParquetConversionSource.java  | 196 ++++++++++++-
 .../xtable/parquet/TestParquetDataManager.java     | 305 +++++++++++++++++++++
 9 files changed, 757 insertions(+), 30 deletions(-)

diff --git 
a/xtable-api/src/main/java/org/apache/xtable/model/InstantsForIncrementalSync.java
 
b/xtable-api/src/main/java/org/apache/xtable/model/InstantsForIncrementalSync.java
index 7c00bf21..af5022dd 100644
--- 
a/xtable-api/src/main/java/org/apache/xtable/model/InstantsForIncrementalSync.java
+++ 
b/xtable-api/src/main/java/org/apache/xtable/model/InstantsForIncrementalSync.java
@@ -48,6 +48,15 @@ import lombok.Value;
 @Builder
 public class InstantsForIncrementalSync {
   Instant lastSyncInstant;
+  // last synced instants by target format for all targets considered for this 
incremental sync.
+  @Builder.Default List<TargetSyncInstant> targetSyncInstants = 
Collections.emptyList();
   // pending commits that are not yet synced and to be considered for next 
incremental sync.
   @Builder.Default List<Instant> pendingCommits = Collections.emptyList();
+
+  @Value
+  @Builder
+  public static class TargetSyncInstant {
+    String tableFormat;
+    Instant lastSyncInstant;
+  }
 }
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 1c138aeb..73bc531a 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
@@ -24,6 +24,7 @@ import java.io.IOException;
 import java.time.Instant;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -43,6 +44,7 @@ import org.apache.xtable.catalog.CatalogConversionFactory;
 import org.apache.xtable.exception.ReadException;
 import org.apache.xtable.model.IncrementalTableChanges;
 import org.apache.xtable.model.InstantsForIncrementalSync;
+import org.apache.xtable.model.InstantsForIncrementalSync.TargetSyncInstant;
 import org.apache.xtable.model.InternalSnapshot;
 import org.apache.xtable.model.catalog.CatalogTableIdentifier;
 import org.apache.xtable.model.metadata.TableSyncMetadata;
@@ -351,10 +353,21 @@ public class ConversionController {
 
   private InstantsForIncrementalSync getMostOutOfSyncCommitAndPendingCommits(
       Map<ConversionTarget, TableSyncMetadata> lastSyncMetadataByFormat) {
+    List<TargetSyncInstant> targetSyncInstants =
+        lastSyncMetadataByFormat.entrySet().stream()
+            .map(
+                entry ->
+                    TargetSyncInstant.builder()
+                        .tableFormat(entry.getKey().getTableFormat())
+                        
.lastSyncInstant(entry.getValue().getLastInstantSynced())
+                        .build())
+            .sorted(
+                Comparator.comparing(TargetSyncInstant::getLastSyncInstant)
+                    .thenComparing(TargetSyncInstant::getTableFormat))
+            .collect(Collectors.toList());
     Instant mostOutOfSyncCommit =
-        lastSyncMetadataByFormat.values().stream()
-            .map(TableSyncMetadata::getLastInstantSynced)
-            .sorted()
+        targetSyncInstants.stream()
+            .map(TargetSyncInstant::getLastSyncInstant)
             .findFirst()
             .orElseThrow(
                 () ->
@@ -368,6 +381,7 @@ public class ConversionController {
             .collect(Collectors.toList());
     return InstantsForIncrementalSync.builder()
         .lastSyncInstant(mostOutOfSyncCommit)
+        .targetSyncInstants(targetSyncInstants)
         .pendingCommits(allPendingInstants)
         .build();
   }
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSource.java
 
b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSource.java
index 61a2ba8f..3a505bb8 100644
--- 
a/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSource.java
+++ 
b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSource.java
@@ -27,12 +27,14 @@ import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashSet;
 import java.util.List;
+import java.util.OptionalLong;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import lombok.Builder;
 import lombok.NonNull;
+import lombok.extern.log4j.Log4j2;
 
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.FileStatus;
@@ -44,6 +46,7 @@ import org.apache.hadoop.util.functional.RemoteIterators;
 import org.apache.parquet.hadoop.metadata.ParquetMetadata;
 import org.apache.parquet.schema.MessageType;
 
+import org.apache.xtable.exception.NotSupportedException;
 import org.apache.xtable.exception.ReadException;
 import org.apache.xtable.hudi.HudiPathUtils;
 import org.apache.xtable.hudi.PathBasedPartitionSpecExtractor;
@@ -63,6 +66,7 @@ import org.apache.xtable.model.storage.TableFormat;
 import org.apache.xtable.spi.extractor.ConversionSource;
 
 @Builder
+@Log4j2
 public class ParquetConversionSource implements ConversionSource<Long> {
 
   private static final ParquetSchemaExtractor schemaExtractor =
@@ -79,6 +83,7 @@ public class ParquetConversionSource implements 
ConversionSource<Long> {
   private final String tableName;
   private final String basePath;
   @NonNull private final Configuration hadoopConf;
+  private final ParquetDataManager parquetDataManager;
 
   private InternalTable createInternalTableFromFile(LocatedFileStatus 
latestFile) {
     ParquetMetadata parquetMetadata =
@@ -130,13 +135,14 @@ public class ParquetConversionSource implements 
ConversionSource<Long> {
                 .build());
   }
 
-  private InternalDataFile createInternalDataFileFromParquetFile(
+  public InternalDataFile createInternalDataFileFromParquetFile(
       FileStatus parquetFile, InternalSchema schema) {
     return InternalDataFile.builder()
         .physicalPath(parquetFile.getPath().toString())
         .partitionValues(
             partitionValueExtractor.extractPartitionValues(
-                partitionSpecExtractor.spec(schema), basePath))
+                partitionSpecExtractor.spec(schema),
+                HudiPathUtils.getPartitionPath(new Path(basePath), 
parquetFile.getPath())))
         .lastModified(parquetFile.getModificationTime())
         .fileSizeBytes(parquetFile.getLen())
         .columnStats(
@@ -148,6 +154,20 @@ public class ParquetConversionSource implements 
ConversionSource<Long> {
 
   @Override
   public CommitsBacklog<Long> getCommitsBacklog(InstantsForIncrementalSync 
syncInstants) {
+    if (syncInstants.getTargetSyncInstants().stream()
+            
.map(InstantsForIncrementalSync.TargetSyncInstant::getLastSyncInstant)
+            .distinct()
+            .count()
+        > 1) {
+      String targetSyncInstants =
+          syncInstants.getTargetSyncInstants().stream()
+              .map(target -> target.getTableFormat() + "=" + 
target.getLastSyncInstant().toString())
+              .collect(Collectors.joining(", "));
+      throw new NotSupportedException(
+          "Parquet incremental sync does not support target formats synced to 
different instants. "
+              + "Run a full sync to realign the target tables. Target 
instants: "
+              + targetSyncInstants);
+    }
     List<Long> commitsToProcess =
         
Collections.singletonList(syncInstants.getLastSyncInstant().toEpochMilli());
     return 
CommitsBacklog.<Long>builder().commitsToProcess(commitsToProcess).build();
@@ -155,14 +175,20 @@ public class ParquetConversionSource implements 
ConversionSource<Long> {
 
   @Override
   public TableChange getTableChangeForCommit(Long modificationTime) {
-    Stream<LocatedFileStatus> parquetFiles = getParquetFiles(hadoopConf, 
basePath);
+    List<LocatedFileStatus> parquetFiles =
+        getParquetFiles(hadoopConf, basePath).collect(Collectors.toList());
     Set<InternalDataFile> addedInternalDataFiles = new HashSet<>();
 
     List<FileStatus> tableChangesAfter =
-        parquetFiles
+        parquetFiles.stream()
             .filter(fileStatus -> fileStatus.getModificationTime() > 
modificationTime)
             .collect(Collectors.toList());
-    InternalTable internalTable = getMostRecentTable(parquetFiles);
+    // Derive both the table and the source identifier from the same snapshot. 
Using a separate
+    // listing for the identifier risks committing a modification time for a 
file that landed after
+    // the snapshot and was not included in filesAdded, which would 
permanently skip that file on
+    // the next incremental sync.
+    LocatedFileStatus mostRecentFile = 
getMostRecentParquetFile(parquetFiles.stream());
+    InternalTable internalTable = createInternalTableFromFile(mostRecentFile);
     for (FileStatus tableStatus : tableChangesAfter) {
       InternalDataFile currentDataFile =
           createInternalDataFileFromParquetFile(tableStatus, 
internalTable.getReadSchema());
@@ -170,6 +196,7 @@ public class ParquetConversionSource implements 
ConversionSource<Long> {
     }
 
     return TableChange.builder()
+        
.sourceIdentifier(getCommitIdentifier(mostRecentFile.getModificationTime()))
         .tableAsOfChange(internalTable)
         
.filesDiff(InternalFilesDiff.builder().filesAdded(addedInternalDataFiles).build())
         .build();
@@ -193,16 +220,17 @@ public class ParquetConversionSource implements 
ConversionSource<Long> {
    */
   @Override
   public InternalSnapshot getCurrentSnapshot() {
-    // to avoid consume the stream call the method twice to return the same 
stream of parquet files
-    InternalTable table = getMostRecentTable(getParquetFiles(hadoopConf, 
basePath));
+    // Fetch the file listing once and reuse it across the table, data files, 
and source identifier.
+    List<LocatedFileStatus> parquetFiles =
+        getParquetFiles(hadoopConf, basePath).collect(Collectors.toList());
+    InternalTable table = getMostRecentTable(parquetFiles.stream());
     Stream<InternalDataFile> internalDataFiles =
-        getInternalDataFiles(getParquetFiles(hadoopConf, basePath), 
table.getReadSchema());
+        getInternalDataFiles(parquetFiles.stream(), table.getReadSchema());
     return InternalSnapshot.builder()
         .table(table)
         .sourceIdentifier(
             getCommitIdentifier(
-                getMostRecentParquetFile(getParquetFiles(hadoopConf, basePath))
-                    .getModificationTime()))
+                
getMostRecentParquetFile(parquetFiles.stream()).getModificationTime()))
         .partitionedDataFiles(PartitionFileGroup.fromFiles(internalDataFiles))
         .build();
   }
@@ -235,8 +263,32 @@ public class ParquetConversionSource implements 
ConversionSource<Long> {
   }
 
   @Override
-  public boolean isIncrementalSyncSafeFrom(Instant instant) {
-    return false;
+  public boolean isIncrementalSyncSafeFrom(Instant timeInMillis) {
+    Stream<ParquetFileInfo> parquetFilesMetadata = 
parquetDataManager.getCurrentFilesInfo();
+    OptionalLong earliestModTimeOpt =
+        
parquetFilesMetadata.mapToLong(ParquetFileInfo::getModificationTime).min();
+
+    if (!earliestModTimeOpt.isPresent()) {
+      log.warn("No parquet files found in table {}. Incremental sync is not 
possible.", tableName);
+      return false;
+    }
+
+    long earliestModTime = earliestModTimeOpt.getAsLong();
+
+    if (earliestModTime > timeInMillis.toEpochMilli()) {
+      log.warn(
+          "Incremental sync is not safe. Earliest available metadata (time={}) 
is newer "
+              + "than requested instant {}.",
+          Instant.ofEpochMilli(earliestModTime),
+          timeInMillis.toEpochMilli());
+      return false;
+    }
+
+    log.debug(
+        "Incremental sync is safe from instant {} for table {}",
+        timeInMillis.toEpochMilli(),
+        tableName);
+    return true;
   }
 
   @Override
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSourceProvider.java
 
b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSourceProvider.java
index 0d508823..81035cea 100644
--- 
a/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSourceProvider.java
+++ 
b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetConversionSourceProvider.java
@@ -32,12 +32,15 @@ public class ParquetConversionSourceProvider extends 
ConversionSourceProvider<Lo
     ParquetPartitionValueExtractor partitionValueExtractor =
         new ParquetPartitionValueExtractor(
             sourcePartitionSpecExtractor.getPathToPartitionFieldFormat());
+    ParquetDataManager dataManager =
+        new ParquetDataManager(this.hadoopConf, sourceTable.getBasePath());
     return ParquetConversionSource.builder()
         .tableName(sourceTable.getName())
         .basePath(sourceTable.getBasePath())
         .hadoopConf(this.hadoopConf)
         .partitionSpecExtractor(sourcePartitionSpecExtractor)
         .partitionValueExtractor(partitionValueExtractor)
+        .parquetDataManager(dataManager)
         .build();
   }
 }
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetDataManager.java 
b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetDataManager.java
new file mode 100644
index 00000000..b66b7491
--- /dev/null
+++ 
b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetDataManager.java
@@ -0,0 +1,96 @@
+/*
+ * 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.parquet;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.LocatedFileStatus;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.RemoteIterator;
+import org.apache.hadoop.util.functional.RemoteIterators;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import org.apache.xtable.exception.ReadException;
+
+/**
+ * Manages Parquet File's Metadata
+ *
+ * <p>This class provides functions to handle Parquet metadata, creating 
metadata objects from
+ * parquet files and filtering the files based on the modification times.
+ */
+@Log4j2
+@RequiredArgsConstructor(
+    access = AccessLevel.PACKAGE,
+    onConstructor_ = {@VisibleForTesting})
+public class ParquetDataManager {
+  private final Configuration hadoopConf;
+  private final String basePath;
+  private final FileSystem fileSystem;
+
+  public ParquetDataManager(Configuration hadoopConf, String basePath) {
+    this.hadoopConf = hadoopConf;
+    this.basePath = basePath;
+    try {
+      URI uri = new Path(basePath).toUri();
+      this.fileSystem = FileSystem.get(uri, hadoopConf);
+    } catch (IOException e) {
+      throw new ReadException("Unable to initialize file system for base path: 
" + basePath, e);
+    }
+  }
+
+  @Getter(value = AccessLevel.PRIVATE, lazy = true)
+  private final List<LocatedFileStatus> parquetFiles = loadParquetFiles();
+
+  ParquetFileInfo getMostRecentParquetFile() {
+    LocatedFileStatus file =
+        getParquetFiles().stream()
+            .max(Comparator.comparing(LocatedFileStatus::getModificationTime))
+            .orElseThrow(() -> new IllegalStateException("No files found"));
+    return new ParquetFileInfo(hadoopConf, file);
+  }
+
+  private List<LocatedFileStatus> loadParquetFiles() {
+    try {
+      RemoteIterator<LocatedFileStatus> iterator = fileSystem.listFiles(new 
Path(basePath), true);
+      return RemoteIterators.toList(iterator).stream()
+          .filter(file -> file.getPath().getName().endsWith("parquet"))
+          .collect(Collectors.toList());
+    } catch (IOException e) {
+      throw new ReadException("Unable to read files from file system", e);
+    }
+  }
+
+  Stream<ParquetFileInfo> getCurrentFilesInfo() {
+    return getParquetFiles().stream()
+        .map(fileStatus -> new ParquetFileInfo(hadoopConf, fileStatus));
+  }
+}
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetFileInfo.java 
b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetFileInfo.java
new file mode 100644
index 00000000..7dfd587f
--- /dev/null
+++ b/xtable-core/src/main/java/org/apache/xtable/parquet/ParquetFileInfo.java
@@ -0,0 +1,51 @@
+/*
+ * 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.parquet;
+
+import lombok.Getter;
+import lombok.Value;
+import lombok.extern.log4j.Log4j2;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.Path;
+import org.apache.parquet.hadoop.metadata.ParquetMetadata;
+
+@Log4j2
+@Value
+class ParquetFileInfo {
+  @Getter(lazy = true)
+  ParquetMetadata metadata = getMetadataInternal();
+
+  long modificationTime;
+  long size;
+  Path path;
+  Configuration conf;
+
+  public ParquetFileInfo(Configuration conf, FileStatus file) {
+    this.modificationTime = file.getModificationTime();
+    this.path = file.getPath();
+    this.size = file.getLen();
+    this.conf = conf;
+  }
+
+  private ParquetMetadata getMetadataInternal() {
+    return ParquetMetadataExtractor.getInstance().readParquetMetadata(conf, 
path);
+  }
+}
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 75323af8..6569c4e8 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
@@ -45,6 +45,7 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentMatcher;
 
@@ -54,6 +55,7 @@ import org.apache.xtable.catalog.CatalogConversionFactory;
 import org.apache.xtable.model.CommitsBacklog;
 import org.apache.xtable.model.IncrementalTableChanges;
 import org.apache.xtable.model.InstantsForIncrementalSync;
+import org.apache.xtable.model.InstantsForIncrementalSync.TargetSyncInstant;
 import org.apache.xtable.model.InternalSnapshot;
 import org.apache.xtable.model.InternalTable;
 import org.apache.xtable.model.TableChange;
@@ -90,6 +92,12 @@ public class TestConversionController {
   private final CatalogSyncClient mockCatalogSyncClient1 = 
mock(CatalogSyncClient.class);
   private final CatalogSyncClient mockCatalogSyncClient2 = 
mock(CatalogSyncClient.class);
 
+  @BeforeEach
+  void setupTargetFormats() {
+    when(mockConversionTarget1.getTableFormat()).thenReturn(ICEBERG);
+    when(mockConversionTarget2.getTableFormat()).thenReturn(DELTA);
+  }
+
   @Test
   void testAllSnapshotSyncAsPerConfig() {
     SyncMode syncMode = SyncMode.FULL;
@@ -165,6 +173,10 @@ public class TestConversionController {
     InstantsForIncrementalSync instantsForIncrementalSync =
         InstantsForIncrementalSync.builder()
             .lastSyncInstant(icebergLastSyncInstant)
+            .targetSyncInstants(
+                Arrays.asList(
+                    targetSyncInstant(ICEBERG, icebergLastSyncInstant),
+                    targetSyncInstant(DELTA, deltaLastSyncInstant)))
             .pendingCommits(combinedPendingInstants)
             .build();
     List<Instant> instantsToProcess =
@@ -307,6 +319,8 @@ public class TestConversionController {
     InstantsForIncrementalSync instantsForIncrementalSync =
         InstantsForIncrementalSync.builder()
             .lastSyncInstant(deltaLastSyncInstant)
+            .targetSyncInstants(
+                Collections.singletonList(targetSyncInstant(DELTA, 
deltaLastSyncInstant)))
             .pendingCommits(pendingInstantsForDelta)
             .build();
     List<Instant> instantsToProcess = Arrays.asList(instantAt8, instantAt2);
@@ -389,10 +403,16 @@ public class TestConversionController {
 
     // Iceberg last synced at instantAt5, the last instant in the source
     Instant icebergLastSyncInstant = instantAt5;
-    // Delta last synced at instantAt10
+    // Delta last synced at instantAt5
     Instant deltaLastSyncInstant = instantAt5;
     InstantsForIncrementalSync instantsForIncrementalSync =
-        
InstantsForIncrementalSync.builder().lastSyncInstant(deltaLastSyncInstant).build();
+        InstantsForIncrementalSync.builder()
+            .lastSyncInstant(deltaLastSyncInstant)
+            .targetSyncInstants(
+                Arrays.asList(
+                    targetSyncInstant(DELTA, deltaLastSyncInstant),
+                    targetSyncInstant(ICEBERG, icebergLastSyncInstant)))
+            .build();
     List<Instant> instantsToProcess = Collections.emptyList();
     CommitsBacklog<Instant> commitsBacklog =
         
CommitsBacklog.<Instant>builder().commitsToProcess(instantsToProcess).build();
@@ -640,4 +660,11 @@ public class TestConversionController {
     }
     return !first.hasNext() && !second.hasNext();
   }
+
+  private static TargetSyncInstant targetSyncInstant(String tableFormat, 
Instant lastSyncInstant) {
+    return TargetSyncInstant.builder()
+        .tableFormat(tableFormat)
+        .lastSyncInstant(lastSyncInstant)
+        .build();
+  }
 }
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/parquet/ITParquetConversionSource.java
 
b/xtable-core/src/test/java/org/apache/xtable/parquet/ITParquetConversionSource.java
index 2ffa579a..be3d0b43 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/parquet/ITParquetConversionSource.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/parquet/ITParquetConversionSource.java
@@ -18,6 +18,7 @@
  
 package org.apache.xtable.parquet;
 
+import static org.apache.spark.sql.functions.expr;
 import static org.apache.xtable.GenericTable.getTableName;
 import static org.apache.xtable.model.storage.TableFormat.DELTA;
 import static org.apache.xtable.model.storage.TableFormat.HUDI;
@@ -25,14 +26,19 @@ import static 
org.apache.xtable.model.storage.TableFormat.ICEBERG;
 import static org.apache.xtable.model.storage.TableFormat.PARQUET;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.IOException;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.sql.Timestamp;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -45,6 +51,11 @@ import java.util.stream.Stream;
 import lombok.Builder;
 import lombok.Value;
 
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileStatus;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.LocatedFileStatus;
+import org.apache.hadoop.fs.RemoteIterator;
 import org.apache.spark.SparkConf;
 import org.apache.spark.api.java.JavaSparkContext;
 import org.apache.spark.sql.Dataset;
@@ -59,6 +70,7 @@ import org.apache.spark.sql.types.StructField;
 import org.apache.spark.sql.types.StructType;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -73,7 +85,15 @@ import org.apache.xtable.conversion.ConversionController;
 import org.apache.xtable.conversion.ConversionSourceProvider;
 import org.apache.xtable.conversion.SourceTable;
 import org.apache.xtable.conversion.TargetTable;
+import org.apache.xtable.exception.NotSupportedException;
 import org.apache.xtable.hudi.HudiTestUtil;
+import org.apache.xtable.model.InstantsForIncrementalSync;
+import org.apache.xtable.model.InstantsForIncrementalSync.TargetSyncInstant;
+import org.apache.xtable.model.InternalSnapshot;
+import org.apache.xtable.model.InternalTable;
+import org.apache.xtable.model.TableChange;
+import org.apache.xtable.model.storage.InternalDataFile;
+import org.apache.xtable.model.storage.TableFormat;
 import org.apache.xtable.model.sync.SyncMode;
 
 public class ITParquetConversionSource {
@@ -167,7 +187,7 @@ public class ITParquetConversionSource {
     return partitionConfigs.stream()
         .flatMap(
             partitionConfig ->
-                Stream.of(SyncMode.FULL) // Incremental sync is not yet 
supported
+                Stream.of(SyncMode.FULL, SyncMode.INCREMENTAL)
                     .map(
                         syncMode ->
                             Arguments.of(
@@ -226,20 +246,17 @@ public class ITParquetConversionSource {
                   new MetadataBuilder().putString("precision", 
"millis").build())
             });
     Dataset<Row> df = sparkSession.createDataFrame(data, schema);
-    String dataPath =
-        tempDir
-            .resolve(
-                (xTablePartitionConfig == null ? "non_partitioned_data_" : 
"partitioned_data_")
-                    + tableFormatPartitionDataHolder.getSyncMode())
-            .toString();
-
-    writeData(df, dataPath, xTablePartitionConfig);
+    Path dataPath =
+        tempDir.resolve(
+            (xTablePartitionConfig == null ? "non_partitioned_data_" : 
"partitioned_data_")
+                + tableFormatPartitionDataHolder.getSyncMode());
+
+    writeData(df, dataPath.toString(), xTablePartitionConfig);
     boolean isPartitioned = xTablePartitionConfig != null;
 
-    Path pathForXTable = Paths.get(dataPath);
     try (GenericTable table =
         GenericTable.getInstance(
-            tableName, pathForXTable, sparkSession, jsc, sourceTableFormat, 
isPartitioned)) {
+            tableName, dataPath, sparkSession, jsc, sourceTableFormat, 
isPartitioned)) {
       ConversionConfig conversionConfig =
           getTableSyncConfig(
               sourceTableFormat,
@@ -266,7 +283,7 @@ public class ITParquetConversionSource {
                   new Timestamp(System.currentTimeMillis() + 1500)));
 
       Dataset<Row> dfAppend = sparkSession.createDataFrame(dataToAppend, 
schema);
-      writeData(dfAppend, dataPath, xTablePartitionConfig);
+      writeData(dfAppend, dataPath.toString(), xTablePartitionConfig);
       ConversionConfig conversionConfigAppended =
           getTableSyncConfig(
               sourceTableFormat,
@@ -418,6 +435,159 @@ public class ITParquetConversionSource {
     }
   }
 
+  @Test
+  void testIncrementalSyncWithMultiplePartitions() throws IOException {
+
+    Configuration conf = sparkSession.sparkContext().hadoopConfiguration();
+
+    StructType schema =
+        DataTypes.createStructType(
+            new StructField[] {
+              DataTypes.createStructField("id", DataTypes.IntegerType, false),
+              DataTypes.createStructField("value", DataTypes.StringType, 
false),
+              DataTypes.createStructField("year", DataTypes.IntegerType, 
false),
+              DataTypes.createStructField("month", DataTypes.IntegerType, 
false)
+            });
+    List<Row> data =
+        Arrays.asList(
+            RowFactory.create(100, "A", 2026, 12),
+            RowFactory.create(101, "AA", 2026, 12),
+            RowFactory.create(102, "CB", 2027, 11),
+            RowFactory.create(103, "BA", 2027, 11));
+
+    Dataset<Row> dfInit = sparkSession.createDataFrame(data, schema);
+    Path fixedPath = 
tempDir.resolve("fixed-parquet-data/parquet_table_test_2");
+
+    Dataset<Row> df = dfInit.withColumn("full_date", expr("make_date(year, 
month, 1)"));
+    String outputPath = fixedPath.toString();
+    df.coalesce(1).write().partitionBy("year", 
"month").mode("overwrite").parquet(outputPath);
+
+    // test find files to sync
+    FileSystem fs = FileSystem.get(fixedPath.toUri(), conf);
+    // set the modification time to the table file
+    // update modificationTime for file to append
+    // many partitions case
+    List<String> newPartitions = Arrays.asList("year=2026/month=12", 
"year=2027/month=11");
+    long targetModificationTime = System.currentTimeMillis() - 360000;
+    long newModificationTime = System.currentTimeMillis() - 50000;
+    long testTime = System.currentTimeMillis() - 90000; // between two prev 
times
+    for (String partition : newPartitions) {
+      org.apache.hadoop.fs.Path partitionPath =
+          new org.apache.hadoop.fs.Path(outputPath, partition);
+      if (fs.exists(partitionPath)) {
+        updateModificationTimeRecursive(fs, partitionPath, 
targetModificationTime);
+      }
+    }
+    // create new file to append using Spark
+    List<Row> futureDataToSync =
+        Arrays.asList(
+            RowFactory.create(101, "A", 2026, 12),
+            RowFactory.create(301, "D", 2027, 11),
+            RowFactory.create(302, "DA", 2027, 11));
+    Dataset<Row> dfToSyncInit = sparkSession.createDataFrame(futureDataToSync, 
schema);
+    Dataset<Row> dfToSync = dfToSyncInit.withColumn("full_date", 
expr("make_date(year, month, 1)"));
+    dfToSync.coalesce(1).write().partitionBy("year", 
"month").mode("append").parquet(outputPath);
+
+    // conversionSource operations
+    Properties sourceProperties = new Properties();
+    String partitionConfig = "full_date:MONTH:year=yyyy/month=MM";
+    sourceProperties.put(PARTITION_FIELD_SPEC_CONFIG, partitionConfig);
+
+    SourceTable tableConfig =
+        SourceTable.builder()
+            .name("parquet_table_test_2")
+            .basePath(fixedPath.toAbsolutePath().toString())
+            .additionalProperties(sourceProperties)
+            .formatName(TableFormat.PARQUET)
+            .build();
+
+    ParquetConversionSourceProvider conversionSourceProvider =
+        new ParquetConversionSourceProvider();
+    conversionSourceProvider.init(conf);
+    ParquetConversionSource conversionSource =
+        conversionSourceProvider.getConversionSourceInstance(tableConfig);
+    Set<InternalDataFile> expectedAddedFiles = new HashSet<>();
+
+    for (String partition : newPartitions) {
+      org.apache.hadoop.fs.Path partitionPath =
+          new org.apache.hadoop.fs.Path(outputPath, partition);
+
+      RemoteIterator<LocatedFileStatus> it = fs.listFiles(partitionPath, true);
+      while (it.hasNext()) {
+        LocatedFileStatus fileStatus = it.next();
+
+        if (fileStatus.getModificationTime() > testTime) {
+          fs.setTimes(fileStatus.getPath(), newModificationTime, -1);
+          FileStatus refreshedStatus = fs.getFileStatus(fileStatus.getPath());
+          expectedAddedFiles.add(
+              conversionSource.createInternalDataFileFromParquetFile(
+                  refreshedStatus, 
conversionSource.getCurrentTable().getReadSchema()));
+        } else {
+          fs.setTimes(fileStatus.getPath(), targetModificationTime, -1);
+        }
+      }
+      fs.setTimes(partitionPath, newModificationTime, -1);
+    }
+    InternalTable result = conversionSource.getTable(newModificationTime);
+    assertEquals(
+        Instant.ofEpochMilli(newModificationTime).toString(),
+        result.getLatestCommitTime().toString());
+    assertEquals("parquet_table_test_2", result.getName());
+    assertEquals(TableFormat.PARQUET, result.getTableFormat());
+    assertNotNull(result.getReadSchema());
+    InternalSnapshot snapshot = conversionSource.getCurrentSnapshot();
+    assertNotNull(snapshot);
+    TableChange changes = conversionSource.getTableChangeForCommit(testTime);
+    assertNotNull(changes);
+    assertFalse(changes.getFilesDiff().dataFilesAdded().isEmpty(), "Should 
have found added files");
+    assertEquals(expectedAddedFiles, changes.getFilesDiff().dataFilesAdded());
+    // The committed source identifier must be the most-recent modification 
time from the same
+    // listing used to compute filesAdded, otherwise a file could be marked 
synced without being
+    // included in the changes.
+    assertEquals(String.valueOf(newModificationTime), 
changes.getSourceIdentifier());
+    Instant instantBeforeFirstSnapshot =
+        
Instant.ofEpochMilli(snapshot.getTable().getLatestCommitTime().toEpochMilli());
+    assertEquals(instantBeforeFirstSnapshot.toEpochMilli(), 
newModificationTime);
+    
assertTrue(conversionSource.isIncrementalSyncSafeFrom(Instant.ofEpochMilli(testTime)));
+  }
+
+  @Test
+  void testGetCommitsBacklog_rejectsDifferentTargetSyncInstants() {
+    ParquetConversionSource source =
+        ParquetConversionSource.builder().hadoopConf(new 
Configuration()).build();
+    InstantsForIncrementalSync syncInstants =
+        InstantsForIncrementalSync.builder()
+            .lastSyncInstant(Instant.ofEpochMilli(1000L))
+            .targetSyncInstants(
+                Arrays.asList(
+                    TargetSyncInstant.builder()
+                        .tableFormat(ICEBERG)
+                        .lastSyncInstant(Instant.ofEpochMilli(1000L))
+                        .build(),
+                    TargetSyncInstant.builder()
+                        .tableFormat(DELTA)
+                        .lastSyncInstant(Instant.ofEpochMilli(2000L))
+                        .build()))
+            .build();
+
+    NotSupportedException exception =
+        assertThrows(NotSupportedException.class, () -> 
source.getCommitsBacklog(syncInstants));
+    assertTrue(exception.getMessage().contains("target formats synced to 
different instants"));
+    
assertTrue(exception.getMessage().contains("ICEBERG=1970-01-01T00:00:01Z"));
+    assertTrue(exception.getMessage().contains("DELTA=1970-01-01T00:00:02Z"));
+  }
+
+  private void updateModificationTimeRecursive(
+      FileSystem fs, org.apache.hadoop.fs.Path path, long time) throws 
IOException {
+    RemoteIterator<LocatedFileStatus> it = fs.listFiles(path, true);
+    while (it.hasNext()) {
+      LocatedFileStatus status = it.next();
+      if (status.getPath().getName().endsWith(".parquet")) {
+        fs.setTimes(status.getPath(), time, -1);
+      }
+    }
+  }
+
   @Builder
   @Value
   private static class TableFormatPartitionDataHolder {
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/parquet/TestParquetDataManager.java
 
b/xtable-core/src/test/java/org/apache/xtable/parquet/TestParquetDataManager.java
new file mode 100644
index 00000000..cce32fb8
--- /dev/null
+++ 
b/xtable-core/src/test/java/org/apache/xtable/parquet/TestParquetDataManager.java
@@ -0,0 +1,305 @@
+/*
+ * 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.parquet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.LocatedFileStatus;
+import org.apache.hadoop.fs.RemoteIterator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class TestParquetDataManager {
+
+  private static final Configuration CONF = new Configuration();
+
+  // Helper method to create a mock LocatedFileStatus
+  private static LocatedFileStatus createMockFileStatus(String name, long 
modTime) {
+    LocatedFileStatus mockStatus = mock(LocatedFileStatus.class);
+    org.apache.hadoop.fs.Path mockPath = mock(org.apache.hadoop.fs.Path.class);
+    when(mockPath.getName()).thenReturn(name);
+    when(mockStatus.getPath()).thenReturn(mockPath);
+    when(mockStatus.getModificationTime()).thenReturn(modTime);
+    when(mockStatus.getLen()).thenReturn(1024L);
+    return mockStatus;
+  }
+
+  private static class StubbedRemoteIterator implements 
RemoteIterator<LocatedFileStatus> {
+    private final Iterator<LocatedFileStatus> files;
+
+    public StubbedRemoteIterator(List<LocatedFileStatus> files) {
+      this.files = files.iterator();
+    }
+
+    @Override
+    public boolean hasNext() {
+      return files.hasNext();
+    }
+
+    @Override
+    public LocatedFileStatus next() {
+      return files.next();
+    }
+  }
+
+  // Helper method to create a mock FileSystem
+  private static FileSystem 
createMockFileSystem(RemoteIterator<LocatedFileStatus> iterator)
+      throws IOException {
+    FileSystem mockFs = mock(FileSystem.class);
+    when(mockFs.listFiles(any(org.apache.hadoop.fs.Path.class), 
anyBoolean())).thenReturn(iterator);
+    return mockFs;
+  }
+
+  private static Set<Long> getModificationTimes(List<ParquetFileInfo> files) {
+    return 
files.stream().map(ParquetFileInfo::getModificationTime).collect(Collectors.toSet());
+  }
+
+  private static Set<Long> modificationTimes(Long... modificationTimes) {
+    return Arrays.stream(modificationTimes).collect(Collectors.toSet());
+  }
+
+  @Test
+  void testGetMostRecentParquetFile_withMultipleFiles() throws IOException {
+    LocatedFileStatus file1 = createMockFileStatus("file1.parquet", 1000L);
+    LocatedFileStatus file2 = createMockFileStatus("file2.parquet", 3000L);
+    LocatedFileStatus file3 = createMockFileStatus("file3.parquet", 2000L);
+
+    RemoteIterator<LocatedFileStatus> iterator =
+        new StubbedRemoteIterator(Arrays.asList(file1, file2, file3));
+    FileSystem mockFs = createMockFileSystem(iterator);
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, "test-path", 
mockFs);
+
+    ParquetFileInfo result = manager.getMostRecentParquetFile();
+
+    assertNotNull(result);
+    assertEquals(3000L, result.getModificationTime());
+  }
+
+  @Test
+  void testGetMostRecentParquetFile_noFiles() throws IOException {
+    RemoteIterator<LocatedFileStatus> iterator = new StubbedRemoteIterator(new 
ArrayList<>());
+    FileSystem mockFs = createMockFileSystem(iterator);
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, "test-path", 
mockFs);
+
+    IllegalStateException exception =
+        assertThrows(IllegalStateException.class, 
manager::getMostRecentParquetFile);
+    assertEquals("No files found", exception.getMessage());
+  }
+
+  @Test
+  void testGetCurrentFilesInfo_multipleFiles() throws IOException {
+    LocatedFileStatus file1 = createMockFileStatus("file1.parquet", 1000L);
+    LocatedFileStatus file2 = createMockFileStatus("file2.parquet", 2000L);
+    LocatedFileStatus file3 = createMockFileStatus("file3.parquet", 3000L);
+
+    RemoteIterator<LocatedFileStatus> iterator =
+        new StubbedRemoteIterator(Arrays.asList(file1, file2, file3));
+    FileSystem mockFs = createMockFileSystem(iterator);
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, "test-path", 
mockFs);
+
+    Stream<ParquetFileInfo> result = manager.getCurrentFilesInfo();
+
+    assertNotNull(result);
+    List<ParquetFileInfo> fileList = result.collect(Collectors.toList());
+    assertEquals(3, fileList.size());
+    assertEquals(modificationTimes(1000L, 2000L, 3000L), 
getModificationTimes(fileList));
+  }
+
+  @Test
+  void testGetCurrentFilesInfo_emptyList() throws IOException {
+    RemoteIterator<LocatedFileStatus> iterator = new 
StubbedRemoteIterator(Collections.emptyList());
+    FileSystem mockFs = createMockFileSystem(iterator);
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, "test-path", 
mockFs);
+
+    Stream<ParquetFileInfo> result = manager.getCurrentFilesInfo();
+
+    assertNotNull(result);
+    assertEquals(0, result.count());
+  }
+
+  @Test
+  void testGetCurrentFilesInfo_streamCharacteristics() throws IOException {
+    LocatedFileStatus file1 = createMockFileStatus("file1.parquet", 1000L);
+    LocatedFileStatus file2 = createMockFileStatus("file2.parquet", 2000L);
+
+    RemoteIterator<LocatedFileStatus> iterator =
+        new StubbedRemoteIterator(Arrays.asList(file1, file2));
+    FileSystem mockFs = createMockFileSystem(iterator);
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, "test-path", 
mockFs);
+
+    Stream<ParquetFileInfo> result = manager.getCurrentFilesInfo();
+
+    assertNotNull(result);
+    assertTrue(result.allMatch(info -> info.getModificationTime() > 0));
+  }
+
+  @Test
+  void testGetParquetFiles_caching() throws IOException {
+    LocatedFileStatus file = createMockFileStatus("file.parquet", 1000L);
+    RemoteIterator<LocatedFileStatus> iterator =
+        new StubbedRemoteIterator(Collections.singletonList(file));
+    FileSystem mockFs = createMockFileSystem(iterator);
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, "test-path", 
mockFs);
+
+    // No filesystem access should happen yet
+    verify(mockFs, never()).listFiles(any(org.apache.hadoop.fs.Path.class), 
anyBoolean());
+
+    // Access multiple times
+    manager.getCurrentFilesInfo();
+    manager.getMostRecentParquetFile();
+    manager.getCurrentFilesInfo();
+
+    // Verify filesystem was accessed only once
+    verify(mockFs, times(1)).listFiles(any(org.apache.hadoop.fs.Path.class), 
anyBoolean());
+  }
+
+  @Test
+  void testOnlyParquetFilesIncluded() throws IOException {
+    LocatedFileStatus parquetFile = createMockFileStatus("data.parquet", 
1000L);
+    LocatedFileStatus txtFile = createMockFileStatus("readme.txt", 2000L);
+    LocatedFileStatus jsonFile = createMockFileStatus("config.json", 3000L);
+
+    RemoteIterator<LocatedFileStatus> iterator =
+        new StubbedRemoteIterator(Arrays.asList(parquetFile, txtFile, 
jsonFile));
+    FileSystem mockFs = createMockFileSystem(iterator);
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, "test-path", 
mockFs);
+
+    List<ParquetFileInfo> result = 
manager.getCurrentFilesInfo().collect(Collectors.toList());
+
+    assertEquals(1, result.size());
+    assertEquals(Collections.singleton(1000L), getModificationTimes(result));
+  }
+
+  @Test
+  void testWithRealFileSystem_multipleFiles(@TempDir Path tempDir) throws 
IOException {
+    // Create multiple parquet files with different modification times
+    Path file1 = tempDir.resolve("data1.parquet");
+    Path file2 = tempDir.resolve("data2.parquet");
+    Path file3 = tempDir.resolve("data3.parquet");
+
+    Files.createFile(file1);
+    Files.createFile(file2);
+    Files.createFile(file3);
+
+    // Set different modification times
+    Files.setLastModifiedTime(file1, FileTime.fromMillis(1000L));
+    Files.setLastModifiedTime(file2, FileTime.fromMillis(3000L));
+    Files.setLastModifiedTime(file3, FileTime.fromMillis(2000L));
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, 
tempDir.toString());
+
+    ParquetFileInfo mostRecent = manager.getMostRecentParquetFile();
+    assertNotNull(mostRecent);
+    assertEquals(3000L, mostRecent.getModificationTime());
+
+    long afterTime =
+        manager.getCurrentFilesInfo().filter(f -> f.getModificationTime() > 
2000L).count();
+    assertEquals(1, afterTime);
+  }
+
+  @Test
+  void testWithRealFileSystem_nestedDirectories(@TempDir Path tempDir) throws 
IOException {
+    // Create nested directory structure
+    Path subDir1 = tempDir.resolve("subdir1");
+    Path subDir2 = tempDir.resolve("subdir2");
+    Files.createDirectories(subDir1);
+    Files.createDirectories(subDir2);
+
+    // Create parquet files in different directories
+    Path file1 = tempDir.resolve("root.parquet");
+    Path file2 = subDir1.resolve("nested1.parquet");
+    Path file3 = subDir2.resolve("nested2.parquet");
+
+    Files.createFile(file1);
+    Files.createFile(file2);
+    Files.createFile(file3);
+
+    Files.setLastModifiedTime(file1, FileTime.fromMillis(1000L));
+    Files.setLastModifiedTime(file2, FileTime.fromMillis(2000L));
+    Files.setLastModifiedTime(file3, FileTime.fromMillis(3000L));
+    ParquetDataManager manager = new ParquetDataManager(CONF, 
tempDir.toString());
+
+    List<ParquetFileInfo> allFiles = 
manager.getCurrentFilesInfo().collect(Collectors.toList());
+    assertEquals(3, allFiles.size());
+
+    ParquetFileInfo mostRecent = manager.getMostRecentParquetFile();
+    assertEquals(3000L, mostRecent.getModificationTime());
+  }
+
+  @Test
+  void testWithRealFileSystem_mixedFileTypes(@TempDir Path tempDir) throws 
IOException {
+    // Create mix of parquet and non-parquet files
+    Path parquetFile1 = tempDir.resolve("data1.parquet");
+    Path parquetFile2 = tempDir.resolve("data2.parquet");
+    Path txtFile = tempDir.resolve("readme.txt");
+    Path jsonFile = tempDir.resolve("config.json");
+
+    Files.createFile(parquetFile1);
+    Files.createFile(parquetFile2);
+    Files.createFile(txtFile);
+    Files.createFile(jsonFile);
+
+    Files.setLastModifiedTime(parquetFile1, FileTime.fromMillis(1000L));
+    Files.setLastModifiedTime(parquetFile2, FileTime.fromMillis(2000L));
+    Files.setLastModifiedTime(txtFile, FileTime.fromMillis(3000L));
+    Files.setLastModifiedTime(jsonFile, FileTime.fromMillis(4000L));
+
+    ParquetDataManager manager = new ParquetDataManager(CONF, 
tempDir.toString());
+
+    List<ParquetFileInfo> allFiles = 
manager.getCurrentFilesInfo().collect(Collectors.toList());
+    // Only parquet files should be included
+    assertEquals(2, allFiles.size());
+
+    ParquetFileInfo mostRecent = manager.getMostRecentParquetFile();
+    // Most recent parquet file, not the txt or json
+    assertEquals(2000L, mostRecent.getModificationTime());
+  }
+}

Reply via email to