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

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 08eb2d9e8429 fix: Parallelize cloud object existence checks in 
S3EventsHoodieIncrSource (#18252)
08eb2d9e8429 is described below

commit 08eb2d9e84294f1eba38c08a9c8b919597c198a9
Author: Vinish Reddy <[email protected]>
AuthorDate: Mon Aug 17 16:16:55 2026 +0530

    fix: Parallelize cloud object existence checks in S3EventsHoodieIncrSource 
(#18252)
    
    * [HUDI-XXXXX] Parallelize cloud object existence checks in 
S3EventsHoodieIncrSource
    
    Repartition by totalExecutorCores and run a per-task thread pool for
    concurrent HEAD requests during file existence checks. Previously all
    checks ran sequentially in a single partition due to upstream Window
    coalescing, causing ~5hr latency for 176K+ files.
    
    - Add EXISTS_CHECK_PARALLELISM config (default 32 threads per task)
    - Repartition distinct() output by totalCores for cluster utilization
    - Add thread pool in getCloudObjectMetadataPerPartition using
      CompletableFuture.supplyAsync with explicit ExecutorService
    - Extract processRow helper, fix per-file INFO log to DEBUG
    - Add tests for parallel and sequential exists check paths
    
    * [HUDI-XXXXX] fix: Use allOf().join() to prevent executor shutdown race in 
parallel exists check
    
    Replace sequential per-future join() with CompletableFuture.allOf().join() 
to
    wait for all futures concurrently inside the try block. This ensures 
shutdownNow()
    only fires after all work is done, preventing interrupted threads when the 
caller
    consumes results. Also adds proper CompletionException handling on the 
fan-in join.
    
    * Fix static import order in CloudObjectsSelectorCommon
    
    * review(18252): fail-fast pool, defaultParallelism repartition, config 
validation, tests
    
    - getObjectMetadata: drop the SparkConf core arithmetic (yielded 1 
partition or repartition(0)
      in common deployments); repartition(jsc.defaultParallelism()) only when 
the exists check is on
    - getCloudObjectMetadataPerPartition: FutureUtils.allOf for fail-fast + 
cancel, named daemon
      threads via CustomizedThreadFactory, unwrap CompletionException so both 
branches throw the
      same HoodieException; keep the sequential branch eager
    - EXISTS_CHECK_PARALLELISM: default 16, sinceVersion 1.3.0, drop dead 
deltastreamer alias,
      document the fs.s3a.connection.maximum interaction; validate >= 1
    - tests: wired S3 getObjectMetadata test with the exists check on 
(S3_FS_PREFIX + parallelism
      from props, distinct, missing file dropped, 0 rejected); parameterized 
{1,8} happy path and
      failure path for the partition function
    
    * review(18252): address round-2 review
    
    - add EXISTS_CHECK_PARTITIONS (0 = spark default parallelism) and clamp the 
count to >= 1;
      extract existsCheckNumPartitions
    - unwrapExistsCheckFailure rethrows any RuntimeException so both branches 
surface the same
      exception; size the pool by min(parallelism, rows) and short-circuit 
empty partitions
    - getUrlForFile copies the Configuration only when the exists check runs
    - config doc: 500 connections on Hadoop 3.4+; javadoc contract for 
existsCheckParallelism
    - tests: URL-encoded event keys (a fixture with a space covers the decode), 
a recording
      LocalFileSystem proves the pooled vs caller thread per branch, failure 
test also covers a
      non-numeric size, explicit dedupe assert, existsCheckNumPartitions 
asserted, @TempDir param
      form like the rest of the file
    
    ---------
    
    Co-authored-by: Lokesh Jain <[email protected]>
    Co-authored-by: voon <[email protected]>
---
 .../hudi/utilities/config/CloudSourceConfig.java   |  21 +++
 .../helpers/CloudObjectsSelectorCommon.java        | 173 ++++++++++++++++-----
 .../helpers/TestCloudObjectsSelectorCommon.java    | 139 +++++++++++++++++
 3 files changed, 295 insertions(+), 38 deletions(-)

diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
index e2242e6a3c87..79dc309d1f65 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
+++ 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
@@ -217,4 +217,25 @@ public class CloudSourceConfig extends HoodieConfig {
           + "Note: the per-read mergeSchema option is honored by Spark's 
native Parquet reader and by Spark's "
           + "native ORC reader (Spark 3.0+, default ORC impl since Spark 2.4). 
On older runtimes the option is "
           + "silently ignored.");
+
+  public static final ConfigProperty<Integer> EXISTS_CHECK_PARALLELISM = 
ConfigProperty
+      .key(STREAMER_CONFIG_PREFIX + 
"source.cloud.data.check.file.exists.parallelism")
+      .defaultValue(16)
+      .markAdvanced()
+      .sinceVersion("1.3.0")
+      .withDocumentation("Number of threads per Spark task used to check cloud 
object existence concurrently when "
+          + ENABLE_EXISTS_CHECK.key() + " is enabled. Must be >= 1; 1 checks 
sequentially. All tasks on an executor "
+          + "share one cached FileSystem client, so keep executor cores x this 
value within the client's connection "
+          + "pool (fs.s3a.connection.maximum, 96 by default on Hadoop 3.3, 500 
on 3.4+) to avoid connection pool timeouts.");
+
+  public static final ConfigProperty<Integer> EXISTS_CHECK_PARTITIONS = 
ConfigProperty
+      .key(STREAMER_CONFIG_PREFIX + 
"source.cloud.data.check.file.exists.partitions")
+      .defaultValue(0)
+      .markAdvanced()
+      .sinceVersion("1.3.0")
+      .withDocumentation("Number of Spark partitions the cloud object 
existence checks are spread over when "
+          + ENABLE_EXISTS_CHECK.key() + " is enabled; each partition checks 
with " + EXISTS_CHECK_PARALLELISM.key()
+          + " threads. The default 0 sizes to the cluster (spark default 
parallelism, i.e. the registered executor "
+          + "cores). Set explicitly to cap the total concurrency (partitions x 
threads) against the storage client's "
+          + "connection pool and request rate limits.");
 }
diff --git 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
index c5a9a6e7a923..be048007f534 100644
--- 
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
+++ 
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
@@ -24,8 +24,11 @@ import org.apache.hudi.common.config.TypedProperties;
 import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.schema.HoodieSchemaField;
 import org.apache.hudi.common.schema.HoodieSchemaType;
+import org.apache.hudi.common.util.CustomizedThreadFactory;
+import org.apache.hudi.common.util.FutureUtils;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.StringUtils;
+import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.exception.HoodieException;
 import org.apache.hudi.exception.HoodieIOException;
 import org.apache.hudi.hadoop.fs.HadoopFSUtils;
@@ -64,15 +67,22 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import static org.apache.hudi.common.util.CollectionUtils.isNullOrEmpty;
 import static org.apache.hudi.common.util.ConfigUtils.containsConfigProperty;
 import static org.apache.hudi.common.util.ConfigUtils.getBooleanWithAltKeys;
+import static org.apache.hudi.common.util.ConfigUtils.getIntWithAltKeys;
 import static org.apache.hudi.common.util.ConfigUtils.getStringWithAltKeys;
 import static 
org.apache.hudi.utilities.config.CloudSourceConfig.CLOUD_DATAFILE_EXTENSION;
 import static 
org.apache.hudi.utilities.config.CloudSourceConfig.CLOUD_INCREMENTAL_MERGE_SCHEMA;
+import static 
org.apache.hudi.utilities.config.CloudSourceConfig.EXISTS_CHECK_PARALLELISM;
+import static 
org.apache.hudi.utilities.config.CloudSourceConfig.EXISTS_CHECK_PARTITIONS;
 import static 
org.apache.hudi.utilities.config.CloudSourceConfig.IGNORE_RELATIVE_PATH_PREFIX;
 import static 
org.apache.hudi.utilities.config.CloudSourceConfig.IGNORE_RELATIVE_PATH_SUBSTR;
 import static 
org.apache.hudi.utilities.config.CloudSourceConfig.PATH_BASED_PARTITION_FIELDS;
@@ -117,35 +127,94 @@ public class CloudObjectsSelectorCommon {
    * @param storageUrlSchemePrefix    Eg: s3:// or gs://. The 
storage-provider-specific prefix to use within the URL.
    * @param storageConf               storage configuration.
    * @param checkIfExists             check if each file exists, before adding 
it to the returned list
-   * @return
+   * @param existsCheckParallelism    number of threads per task for the 
existence checks (getObjectMetadata validates it is >= 1); 1 checks sequentially
    */
   public static MapPartitionsFunction<Row, CloudObjectMetadata> 
getCloudObjectMetadataPerPartition(
-      String storageUrlSchemePrefix, StorageConfiguration<Configuration> 
storageConf, boolean checkIfExists) {
+      String storageUrlSchemePrefix, StorageConfiguration<Configuration> 
storageConf,
+      boolean checkIfExists, int existsCheckParallelism) {
     return rows -> {
-      List<CloudObjectMetadata> cloudObjectMetadataPerPartition = new 
ArrayList<>();
-      rows.forEachRemaining(row -> {
-        Option<String> filePathUrl = getUrlForFile(row, 
storageUrlSchemePrefix, storageConf, checkIfExists);
-        filePathUrl.ifPresent(url -> {
-          log.info("Adding file: {}", url);
-          long size;
-          Object obj = row.get(2);
-          if (obj instanceof String) {
-            size = Long.parseLong((String) obj);
-          } else if (obj instanceof Integer) {
-            size = ((Integer) obj).longValue();
-          } else if (obj instanceof Long) {
-            size = (long) obj;
-          } else {
-            throw new HoodieIOException("unexpected object size's type in 
Cloud storage events: " + obj.getClass());
-          }
-          cloudObjectMetadataPerPartition.add(new CloudObjectMetadata(url, 
size));
-        });
-      });
+      if (!checkIfExists || existsCheckParallelism <= 1) {
+        List<CloudObjectMetadata> cloudObjectMetadataPerPartition = new 
ArrayList<>();
+        rows.forEachRemaining(row ->
+            processRow(row, storageUrlSchemePrefix, storageConf, 
checkIfExists).ifPresent(cloudObjectMetadataPerPartition::add));
+        return cloudObjectMetadataPerPartition.iterator();
+      }
 
-      return cloudObjectMetadataPerPartition.iterator();
+      List<Row> rowList = new ArrayList<>();
+      rows.forEachRemaining(rowList::add);
+      if (rowList.isEmpty()) {
+        return Collections.emptyIterator();
+      }
+      ExecutorService executor = 
Executors.newFixedThreadPool(Math.min(existsCheckParallelism, rowList.size()),
+          new CustomizedThreadFactory("cloud-exists-check", true));
+      try {
+        List<CompletableFuture<Option<CloudObjectMetadata>>> futures = 
rowList.stream()
+            .map(row -> CompletableFuture.supplyAsync(
+                () -> processRow(row, storageUrlSchemePrefix, storageConf, 
true), executor))
+            .collect(Collectors.toList());
+        List<Option<CloudObjectMetadata>> results;
+        try {
+          // fails fast: the first failed check completes the returned future 
exceptionally and cancels the rest,
+          // and every future is complete before this returns, so the pool can 
be shut down right after
+          results = FutureUtils.allOf(futures).join();
+        } catch (CompletionException e) {
+          throw unwrapExistsCheckFailure(e);
+        }
+        return results.stream()
+            .filter(Option::isPresent)
+            .map(Option::get)
+            .collect(Collectors.toList())
+            .iterator();
+      } finally {
+        // on the failure path this also interrupts the in-flight checks that 
FutureUtils.allOf only cancelled
+        executor.shutdownNow();
+      }
     };
   }
 
+  /**
+   * {@link FutureUtils#allOf} wraps the failure of a check in one or more 
{@link CompletionException}s; rethrow the
+   * original exception so callers see the same failure the sequential path 
throws, whatever the parallelism.
+   */
+  private static RuntimeException unwrapExistsCheckFailure(CompletionException 
e) {
+    Throwable cause = e;
+    while (cause instanceof CompletionException && cause.getCause() != null) {
+      cause = cause.getCause();
+    }
+    if (cause instanceof Error) {
+      throw (Error) cause;
+    }
+    return cause instanceof RuntimeException
+        ? (RuntimeException) cause
+        : new HoodieException("Failed during parallel cloud object existence 
check", cause);
+  }
+
+  /**
+   * Process a single row to build a {@link CloudObjectMetadata}. Optionally 
checks if the file exists.
+   */
+  private static Option<CloudObjectMetadata> processRow(Row row, String 
storageUrlSchemePrefix,
+                                                        
StorageConfiguration<Configuration> storageConf,
+                                                        boolean checkIfExists) 
{
+    Option<String> filePathUrl = getUrlForFile(row, storageUrlSchemePrefix, 
storageConf, checkIfExists);
+    if (!filePathUrl.isPresent()) {
+      return Option.empty();
+    }
+    String url = filePathUrl.get();
+    log.debug("Adding file: {}", url);
+    long size;
+    Object obj = row.get(2);
+    if (obj instanceof String) {
+      size = Long.parseLong((String) obj);
+    } else if (obj instanceof Integer) {
+      size = ((Integer) obj).longValue();
+    } else if (obj instanceof Long) {
+      size = (long) obj;
+    } else {
+      throw new HoodieIOException("unexpected object size's type in Cloud 
storage events: " + obj.getClass());
+    }
+    return Option.of(new CloudObjectMetadata(url, size));
+  }
+
   /**
    * Construct a full qualified URL string to a cloud file from a given Row. 
Optionally check if the file exists.
    * Here Row is assumed to have the schema [bucket_name, 
filepath_relative_to_bucket].
@@ -157,8 +226,6 @@ public class CloudObjectsSelectorCommon {
   private static Option<String> getUrlForFile(Row row, String 
storageUrlSchemePrefix,
                                               
StorageConfiguration<Configuration> storageConf,
                                               boolean checkIfExists) {
-    final Configuration configuration = storageConf.unwrapCopy();
-
     String bucket = row.getString(0);
     String filePath = storageUrlSchemePrefix + bucket + StoragePath.SEPARATOR 
+ row.getString(1);
 
@@ -167,7 +234,7 @@ public class CloudObjectsSelectorCommon {
       if (!checkIfExists) {
         return Option.of(filePathUrl);
       }
-      boolean exists = checkIfFileExists(storageUrlSchemePrefix, bucket, 
filePathUrl, configuration);
+      boolean exists = checkIfFileExists(storageUrlSchemePrefix, bucket, 
filePathUrl, storageConf.unwrapCopy());
       return exists ? Option.of(filePathUrl) : Option.empty();
     } catch (Exception exception) {
       log.error("Failed to generate path to cloud file {}", filePath, 
exception);
@@ -256,22 +323,52 @@ public class CloudObjectsSelectorCommon {
       TypedProperties props
   ) {
     StorageConfiguration<Configuration> storageConf = 
HadoopFSUtils.getStorageConfWithCopy(jsc.hadoopConfiguration());
+    int existsCheckParallelism = getIntWithAltKeys(props, 
EXISTS_CHECK_PARALLELISM);
+    ValidationUtils.checkArgument(existsCheckParallelism >= 1,
+        EXISTS_CHECK_PARALLELISM.key() + " must be >= 1, got: " + 
existsCheckParallelism);
+
+    String prefix;
+    String bucketCol;
+    String keyCol;
+    String sizeCol;
     if (type == Type.GCS) {
-      return cloudObjectMetadataDF
-          .select("bucket", "name", "size")
-          .distinct()
-          .mapPartitions(getCloudObjectMetadataPerPartition(GCS_PREFIX, 
storageConf, checkIfExists), Encoders.kryo(CloudObjectMetadata.class))
-          .collectAsList();
+      prefix = GCS_PREFIX;
+      bucketCol = "bucket";
+      keyCol = GCS_OBJECT_KEY;
+      sizeCol = GCS_OBJECT_SIZE;
     } else if (type == Type.S3) {
       String s3FS = getStringWithAltKeys(props, S3_FS_PREFIX, 
true).toLowerCase();
-      String s3Prefix = s3FS + "://";
-      return cloudObjectMetadataDF
-          .select(CloudObjectsSelectorCommon.S3_BUCKET_NAME, 
CloudObjectsSelectorCommon.S3_OBJECT_KEY, 
CloudObjectsSelectorCommon.S3_OBJECT_SIZE)
-          .distinct()
-          .mapPartitions(getCloudObjectMetadataPerPartition(s3Prefix, 
storageConf, checkIfExists), Encoders.kryo(CloudObjectMetadata.class))
-          .collectAsList();
-    }
-    throw new UnsupportedOperationException("Invalid cloud type " + type);
+      prefix = s3FS + "://";
+      bucketCol = S3_BUCKET_NAME;
+      keyCol = S3_OBJECT_KEY;
+      sizeCol = S3_OBJECT_SIZE;
+    } else {
+      throw new UnsupportedOperationException("Invalid cloud type " + type);
+    }
+
+    Dataset<Row> distinctObjects = cloudObjectMetadataDF.select(bucketCol, 
keyCol, sizeCol).distinct();
+    if (checkIfExists) {
+      // The upstream Window.orderBy() in IncrSourceHelper collapses the 
dataset to one partition and AQE keeps the
+      // distinct() output there, which would serialize every existence check 
on one task. Spread the checks over
+      // the cluster: repartition(n) is not coalesced by AQE, and 
defaultParallelism tracks the registered cores.
+      int numPartitions = existsCheckNumPartitions(props, jsc);
+      log.info("Checking cloud object existence over {} partitions with {} 
threads per task", numPartitions, existsCheckParallelism);
+      distinctObjects = distinctObjects.repartition(numPartitions);
+    }
+    return distinctObjects
+        .mapPartitions(
+            getCloudObjectMetadataPerPartition(prefix, storageConf, 
checkIfExists, existsCheckParallelism),
+            Encoders.kryo(CloudObjectMetadata.class))
+        .collectAsList();
+  }
+
+  /**
+   * Number of partitions the existence checks are spread over: {@link 
CloudSourceConfig#EXISTS_CHECK_PARTITIONS}
+   * when set to a positive value, otherwise the Spark default parallelism. 
Never below 1 (repartition rejects 0).
+   */
+  static int existsCheckNumPartitions(TypedProperties props, JavaSparkContext 
jsc) {
+    int configured = getIntWithAltKeys(props, EXISTS_CHECK_PARTITIONS);
+    return Math.max(1, configured > 0 ? configured : jsc.defaultParallelism());
   }
 
   public Option<Dataset<Row>> loadAsDataset(SparkSession spark, 
List<CloudObjectMetadata> cloudObjectMetadata,
diff --git 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
index f7cb663fa320..3085d3db08e6 100644
--- 
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
+++ 
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
@@ -23,12 +23,20 @@ import org.apache.hudi.common.config.TypedProperties;
 import org.apache.hudi.common.schema.HoodieSchema;
 import org.apache.hudi.common.schema.HoodieSchemaField;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
 import org.apache.hudi.testutils.HoodieSparkClientTestHarness;
 import org.apache.hudi.utilities.config.CloudSourceConfig;
+import org.apache.hudi.utilities.config.S3EventsHoodieIncrSourceConfig;
 import org.apache.hudi.utilities.schema.FilebasedSchemaProvider;
 import org.apache.hudi.utilities.schema.RowBasedSchemaProvider;
 
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.LocalFileSystem;
+import org.apache.spark.api.java.function.MapPartitionsFunction;
 import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Encoders;
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.RowFactory;
 import org.apache.spark.sql.types.DataTypes;
@@ -40,6 +48,7 @@ import org.junit.jupiter.api.BeforeEach;
 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.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.File;
@@ -50,8 +59,10 @@ import java.nio.file.Path;
 import java.util.ArrayList;
 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.Set;
 import java.util.stream.Collectors;
 
@@ -329,6 +340,104 @@ public class TestCloudObjectsSelectorCommon extends 
HoodieSparkClientTestHarness
     
Assertions.assertEquals(Collections.singletonList(RowFactory.create(expectedPath,
 "some data")), result.get().collectAsList());
   }
 
+  @Test
+  void s3ObjectMetadataDedupesAndFiltersMissingFilesWithExistsCheck(@TempDir 
Path tempDir) throws IOException {
+    // a space in the name: the event key is percent-encoded and getUrlForFile 
must decode it
+    Path existingFile1 = Files.write(tempDir.resolve("we ird.json"), 
Collections.singletonList("{}"));
+    Path existingFile2 = Files.write(tempDir.resolve("file2.json"), 
Collections.singletonList("{}"));
+    Path missingFile = tempDir.resolve("missing.json");
+    // bucket "" + key = absolute path without its leading separator, so 
prefix + bucket + "/" + key is a file:// URL
+    List<String> jsonRecords = Arrays.asList(
+        s3EventJson(existingFile1, 100),
+        s3EventJson(existingFile2, 200),
+        s3EventJson(missingFile, 300),
+        // duplicate event, dropped by distinct()
+        s3EventJson(existingFile1, 100));
+    Dataset<Row> cloudObjectMetadataDF = 
sparkSession.read().json(sparkSession.createDataset(jsonRecords, 
Encoders.STRING()));
+    TypedProperties props = new TypedProperties();
+    props.put(S3EventsHoodieIncrSourceConfig.S3_FS_PREFIX.key(), "file");
+    props.put(CloudSourceConfig.EXISTS_CHECK_PARALLELISM.key(), "0");
+    Assertions.assertThrows(IllegalArgumentException.class, () -> 
CloudObjectsSelectorCommon.getObjectMetadata(
+        CloudObjectsSelectorCommon.Type.S3, jsc, cloudObjectMetadataDF, true, 
props));
+
+    props.put(CloudSourceConfig.EXISTS_CHECK_PARALLELISM.key(), "4");
+    Assertions.assertEquals(jsc.defaultParallelism(), 
CloudObjectsSelectorCommon.existsCheckNumPartitions(props, jsc));
+    props.put(CloudSourceConfig.EXISTS_CHECK_PARTITIONS.key(), "2");
+    Assertions.assertEquals(2, 
CloudObjectsSelectorCommon.existsCheckNumPartitions(props, jsc));
+    List<CloudObjectMetadata> result = 
CloudObjectsSelectorCommon.getObjectMetadata(
+        CloudObjectsSelectorCommon.Type.S3, jsc, cloudObjectMetadataDF, true, 
props);
+
+    Assertions.assertEquals(2, result.size(), "distinct() should have 
collapsed the duplicate event");
+    Map<String, Long> pathToSize = result.stream()
+        .collect(Collectors.toMap(CloudObjectMetadata::getPath, 
CloudObjectMetadata::getSize));
+    Map<String, Long> expected = new HashMap<>();
+    expected.put("file://" + existingFile1.toAbsolutePath(), 100L);
+    expected.put("file://" + existingFile2.toAbsolutePath(), 200L);
+    Assertions.assertEquals(expected, pathToSize);
+  }
+
+  @ParameterizedTest
+  @ValueSource(ints = {1, 8})
+  void existsCheckDropsMissingFiles(int parallelism, @TempDir Path tempDir) 
throws Exception {
+    // a space in the name: the event key is percent-encoded and getUrlForFile 
must decode it
+    Path existingFile1 = Files.write(tempDir.resolve("we ird.json"), 
Collections.singletonList("{}"));
+    Path existingFile2 = Files.write(tempDir.resolve("file2.json"), 
Collections.singletonList("{}"));
+    Path missingFile = tempDir.resolve("missing.json");
+    List<Row> rows = Arrays.asList(
+        cloudEventRow(existingFile1, 100L),
+        cloudEventRow(missingFile, 300L),
+        cloudEventRow(existingFile2, 200L));
+
+    Configuration conf = storageConf.unwrapCopy();
+    conf.setClass("fs.file.impl", ThreadRecordingLocalFileSystem.class, 
FileSystem.class);
+    conf.setBoolean("fs.file.impl.disable.cache", true);
+    ThreadRecordingLocalFileSystem.EXISTS_CALL_THREADS.clear();
+
+    List<CloudObjectMetadata> result = new ArrayList<>();
+    CloudObjectsSelectorCommon.getCloudObjectMetadataPerPartition(
+            "file://", new HadoopStorageConfiguration(conf), true, parallelism)
+        .call(rows.iterator()).forEachRemaining(result::add);
+
+    // both branches must produce the same objects, in input order
+    Assertions.assertEquals(
+        Arrays.asList("file://" + existingFile1.toAbsolutePath(), "file://" + 
existingFile2.toAbsolutePath()),
+        
result.stream().map(CloudObjectMetadata::getPath).collect(Collectors.toList()));
+    Assertions.assertEquals(Arrays.asList(100L, 200L),
+        
result.stream().map(CloudObjectMetadata::getSize).collect(Collectors.toList()));
+
+    // one exists() per row; on pool threads for the parallel branch, on the 
caller thread for the sequential one
+    List<String> threads = new 
ArrayList<>(ThreadRecordingLocalFileSystem.EXISTS_CALL_THREADS);
+    Assertions.assertEquals(3, threads.size(), threads.toString());
+    if (parallelism > 1) {
+      Assertions.assertTrue(threads.stream().allMatch(t -> 
t.startsWith("cloud-exists-check-")), threads.toString());
+    } else {
+      
Assertions.assertEquals(Collections.singleton(Thread.currentThread().getName()),
 new HashSet<>(threads));
+    }
+  }
+
+  @ParameterizedTest
+  @CsvSource({"1, escape", "8, escape", "1, size", "8, size"})
+  void existsCheckSurfacesRowFailure(int parallelism, String failure, @TempDir 
Path tempDir) throws Exception {
+    // both branches must throw the same exception instead of dropping the 
file: a key with a malformed percent
+    // escape fails URL decoding inside getUrlForFile (HoodieException), a 
non-numeric size string fails
+    // Long.parseLong in processRow (raw NumberFormatException)
+    Path existingFile = Files.write(tempDir.resolve("file1.json"), 
Collections.singletonList("{}"));
+    Row badRow = failure.equals("escape")
+        ? RowFactory.create("", "path/bad%zz.json", 200L)
+        : RowFactory.create("", localFileKey(existingFile), "not-a-number");
+    List<Row> rows = Arrays.asList(cloudEventRow(existingFile, 100L), badRow);
+    MapPartitionsFunction<Row, CloudObjectMetadata> fn =
+        
CloudObjectsSelectorCommon.getCloudObjectMetadataPerPartition("file://", 
storageConf, true, parallelism);
+
+    if (failure.equals("escape")) {
+      HoodieException e = Assertions.assertThrows(HoodieException.class, () -> 
fn.call(rows.iterator()));
+      Assertions.assertTrue(e.getMessage().contains("path/bad%zz.json"), 
e.getMessage());
+      Assertions.assertInstanceOf(IllegalArgumentException.class, 
e.getCause());
+    } else {
+      Assertions.assertThrows(NumberFormatException.class, () -> 
fn.call(rows.iterator()));
+    }
+  }
+
   /**
    * Asserts that a Dataset contains expected rows; when the source path 
column is enabled it is expected
    * to be appended last, nullable, and to hold the file URI of the row's 
source file.
@@ -365,4 +474,34 @@ public class TestCloudObjectsSelectorCommon extends 
HoodieSparkClientTestHarness
   private static void setIncludeSourcePathField(TypedProperties properties, 
boolean include) {
     properties.put(CloudSourceConfig.INCLUDE_SOURCE_PATH_FIELD.key(), 
String.valueOf(include));
   }
+
+  /** Row with the [bucket, key, size] shape 
getCloudObjectMetadataPerPartition expects; see s3EventJson for the key. */
+  private static Row cloudEventRow(Path file, long size) {
+    return RowFactory.create("", localFileKey(file), size);
+  }
+
+  /** S3 event notification whose bucket is empty and whose key is the 
URL-encoded absolute path without the leading
+   * separator (the shape S3 event notifications carry), so that prefix 
"file://" + bucket + "/" + key decodes to the
+   * local file. */
+  private static String s3EventJson(Path file, long size) {
+    return "{\"s3\":{\"bucket\":{\"name\":\"\"},\"object\":{\"key\":\"" + 
localFileKey(file) + "\",\"size\":" + size + "}}}";
+  }
+
+  private static String localFileKey(Path file) {
+    return file.toUri().getRawPath().substring(1);
+  }
+
+  /**
+   * LocalFileSystem that records the thread each exists() call ran on, so the 
tests can tell the pooled branch
+   * from the sequential one. Registered as fs.file.impl with the FileSystem 
cache disabled.
+   */
+  public static class ThreadRecordingLocalFileSystem extends LocalFileSystem {
+    static final List<String> EXISTS_CALL_THREADS = 
Collections.synchronizedList(new ArrayList<>());
+
+    @Override
+    public boolean exists(org.apache.hadoop.fs.Path f) throws IOException {
+      EXISTS_CALL_THREADS.add(Thread.currentThread().getName());
+      return super.exists(f);
+    }
+  }
 }

Reply via email to