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

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


The following commit(s) were added to refs/heads/master by this push:
     new ca17e52868e [FLINK-40593][s3] Batch recursive S3 deletes in native s3 
connector with global and per-bucket opt-out
ca17e52868e is described below

commit ca17e52868e2da8995c9d7a2dcb942c083a28bc1
Author: Gabor Somogyi <[email protected]>
AuthorDate: Thu Sep 10 13:05:33 2026 +0200

    [FLINK-40593][s3] Batch recursive S3 deletes in native s3 connector with 
global and per-bucket opt-out
    
    * [FLINK-40593][s3] Batch recursive S3 deletes in native s3 connector with 
global and per-bucket opt-out
---
 flink-filesystems/flink-s3-fs-native/README.md     |   2 +
 .../flink/fs/s3native/BucketConfigProvider.java    |  12 ++
 .../flink/fs/s3native/NativeS3FileSystem.java      |  22 ++-
 .../fs/s3native/NativeS3FileSystemFactory.java     |  17 +-
 .../flink/fs/s3native/NativeS3RecursiveDelete.java | 150 +++++++++++++++++
 .../apache/flink/fs/s3native/S3BucketConfig.java   |  25 ++-
 .../fs/s3native/BucketConfigProviderTest.java      |  15 +-
 .../fs/s3native/NativeS3FileSystemFactoryTest.java |  23 +++
 .../fs/s3native/NativeS3FileSystemITCase.java      |  33 +++-
 .../fs/s3native/NativeS3RecursiveDeleteTest.java   | 182 +++++++++++++++++++++
 .../src/test/resources/log4j2-test.properties      |  28 ++++
 11 files changed, 495 insertions(+), 14 deletions(-)

diff --git a/flink-filesystems/flink-s3-fs-native/README.md 
b/flink-filesystems/flink-s3-fs-native/README.md
index b65583f989d..7f45ffe13ab 100644
--- a/flink-filesystems/flink-s3-fs-native/README.md
+++ b/flink-filesystems/flink-s3-fs-native/README.md
@@ -77,6 +77,7 @@ input.sinkTo(FileSink.forRowFormat(new 
Path("s3://my-bucket/output"),
 | s3.connection.max | 50 | Maximum HTTP connections in the S3 client 
connection pool. Applies to sync and async clients, including CRT when enabled. 
Must be ≥ `s3.bulk-copy.max-concurrent` |
 | s3.async.enabled | true | Enable async read/write with TransferManager |
 | s3.read.buffer.size | 262144 (256KB) | Read buffer size per stream (64KB - 
4MB) |
+| s3.delete.batch.enabled | true | Use S3's batch `DeleteObjects` API when 
recursively deleting a directory, instead of issuing one `DeleteObject` call 
per file. Disable for S3-compatible stores that do not support multi-object 
delete |
 
 ### Metrics
 
@@ -156,6 +157,7 @@ Only the following properties can be overridden at the 
bucket level. Any other `
 - **Credentials:** `access-key`, `secret-key`, `aws.credentials.provider`
 - **Encryption:** `sse.type`, `sse.kms.key-id`
 - **IAM Assume Role:** `assume-role.arn`, `assume-role.external-id`, 
`assume-role.session-name`, `assume-role.session-duration`
+- **Delete behavior:** `delete.batch.enabled`
 
 Timeouts, retries, encoding/checksum flags, entropy, upload/copy settings, and 
the credentials provider chain are configured globally only.
 
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/BucketConfigProvider.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/BucketConfigProvider.java
index 05355f8d4a0..248a4100b87 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/BucketConfigProvider.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/BucketConfigProvider.java
@@ -76,6 +76,18 @@ final class BucketConfigProvider {
                 });
         applicators.put("assume-role.session-name", 
S3BucketConfig.Builder::assumeRoleSessionName);
         applicators.put("aws.credentials.provider", 
S3BucketConfig.Builder::credentialsProvider);
+        applicators.put(
+                "delete.batch.enabled",
+                (b, v) -> {
+                    if (!"true".equalsIgnoreCase(v) && 
!"false".equalsIgnoreCase(v)) {
+                        throw new IllegalConfigurationException(
+                                String.format(
+                                        "Invalid delete.batch.enabled '%s' for 
bucket '%s'. "
+                                                + "Must be 'true' or 'false'",
+                                        v, b.getBucketName()));
+                    }
+                    b.deleteBatchEnabled(Boolean.parseBoolean(v));
+                });
         applicators.put("endpoint", S3BucketConfig.Builder::endpoint);
         applicators.put(
                 "path-style-access",
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
index efec0dcc185..ebb4a603a5d 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystem.java
@@ -114,6 +114,7 @@ class NativeS3FileSystem extends FileSystem
     private final boolean useAsyncOperations;
     private final int readBufferSize;
     private final Duration fsCloseTimeout;
+    private final boolean deleteBatchEnabled;
     private final AtomicBoolean closed = new AtomicBoolean(false);
 
     public NativeS3FileSystem(
@@ -127,7 +128,8 @@ class NativeS3FileSystem extends FileSystem
             @Nullable NativeS3BulkCopyHelper bulkCopyHelper,
             boolean useAsyncOperations,
             int readBufferSize,
-            Duration fsCloseTimeout) {
+            Duration fsCloseTimeout,
+            boolean deleteBatchEnabled) {
         this.clientProvider =
                 Preconditions.checkNotNull(clientProvider, "clientProvider 
must not be null");
         this.uri = uri;
@@ -140,6 +142,7 @@ class NativeS3FileSystem extends FileSystem
         this.useAsyncOperations = useAsyncOperations;
         this.readBufferSize = readBufferSize;
         this.fsCloseTimeout = fsCloseTimeout;
+        this.deleteBatchEnabled = deleteBatchEnabled;
         this.s3AccessHelper =
                 new NativeS3ObjectOperations(
                         clientProvider.getS3Client(),
@@ -155,11 +158,12 @@ class NativeS3FileSystem extends FileSystem
         }
 
         LOG.info(
-                "Created Native S3 FileSystem for bucket: {}, entropy 
injection: {}, bulk copy: {}, read buffer: {} KB",
+                "Created Native S3 FileSystem for bucket: {}, entropy 
injection: {}, bulk copy: {}, read buffer: {} KB, delete batching: {}",
                 bucketName,
                 entropyInjectionKey != null,
                 bulkCopyHelper != null,
-                readBufferSize / 1024);
+                readBufferSize / 1024,
+                deleteBatchEnabled);
     }
 
     @VisibleForTesting
@@ -167,6 +171,11 @@ class NativeS3FileSystem extends FileSystem
         return fsCloseTimeout;
     }
 
+    @VisibleForTesting
+    boolean isDeleteBatchEnabled() {
+        return deleteBatchEnabled;
+    }
+
     @VisibleForTesting
     S3ClientProvider getClientProvider() {
         return clientProvider;
@@ -392,11 +401,8 @@ class NativeS3FileSystem extends FileSystem
                     throw new IOException("Directory not empty and recursive = 
false");
                 }
 
-                final FileStatus[] contents = listStatus(path);
-                for (FileStatus file : contents) {
-                    delete(file.getPath(), true);
-                }
-
+                new NativeS3RecursiveDelete(s3Client, bucketName, key, 
deleteBatchEnabled)
+                        .execute();
                 return true;
             }
         } catch (FileNotFoundException e) {
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
index ff52b2dfb0a..63f6ac118ee 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactory.java
@@ -188,6 +188,16 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory, MetricsAwar
                     .withDescription(
                             "Enable async read/write operations using 
S3TransferManager for improved performance");
 
+    public static final ConfigOption<Boolean> DELETE_BATCH_ENABLED =
+            ConfigOptions.key("s3.delete.batch.enabled")
+                    .booleanType()
+                    .defaultValue(true)
+                    .withDescription(
+                            "Use S3's batch DeleteObjects API when recursively 
deleting a "
+                                    + "directory, instead of issuing one 
DeleteObject call per "
+                                    + "file. Disable for S3-compatible stores 
that don't support "
+                                    + "multi-object delete.");
+
     public static final ConfigOption<Integer> READ_BUFFER_SIZE =
             ConfigOptions.key("s3.read.buffer.size")
                     .intType()
@@ -529,6 +539,7 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory, MetricsAwar
         String assumeRoleSessionName = config.get(ASSUME_ROLE_SESSION_NAME);
         int assumeRoleSessionDuration = 
config.get(ASSUME_ROLE_SESSION_DURATION_SECONDS);
         String credentialsProviderClasses = 
config.get(AWS_CREDENTIALS_PROVIDER);
+        boolean deleteBatchEnabled = config.get(DELETE_BATCH_ENABLED);
 
         // Apply bucket-specific overrides
         String bucketName = fsUri.getHost();
@@ -562,6 +573,9 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory, MetricsAwar
                 if (overrides.getAssumeRoleSessionDurationSeconds() != null) {
                     assumeRoleSessionDuration = 
overrides.getAssumeRoleSessionDurationSeconds();
                 }
+                if (overrides.getDeleteBatchEnabled() != null) {
+                    deleteBatchEnabled = overrides.getDeleteBatchEnabled();
+                }
             }
         }
 
@@ -750,7 +764,8 @@ public class NativeS3FileSystemFactory implements 
FileSystemFactory, MetricsAwar
                 bulkCopyHelper,
                 useAsyncOperations,
                 readBufferSize,
-                config.get(FS_CLOSE_TIMEOUT));
+                config.get(FS_CLOSE_TIMEOUT),
+                deleteBatchEnabled);
     }
 
     @Nullable
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3RecursiveDelete.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3RecursiveDelete.java
new file mode 100644
index 00000000000..ba2c90c05b6
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/NativeS3RecursiveDelete.java
@@ -0,0 +1,150 @@
+/*
+ * 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.flink.fs.s3native;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.Delete;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
+import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
+import software.amazon.awssdk.services.s3.model.S3Error;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A single recursive deletion of every object under one key prefix. 
Instantiated per {@code
+ * delete(path, true)} call; walks the prefix one listing page at a time so 
memory use stays bounded
+ * by the page size rather than the size of the whole subtree, since each page 
maps directly onto
+ * one {@code DeleteObjects} request.
+ */
+final class NativeS3RecursiveDelete {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(NativeS3RecursiveDelete.class);
+
+    /** Also used as the listing page size (the S3 {@code DeleteObjects} 
limit). */
+    private static final int PAGE_SIZE = 1000;
+
+    private final S3Client s3Client;
+    private final String bucketName;
+    private final String prefix;
+    private final boolean batchEnabled;
+
+    NativeS3RecursiveDelete(
+            S3Client s3Client, String bucketName, String key, boolean 
batchEnabled) {
+        this.s3Client = s3Client;
+        this.bucketName = bucketName;
+        this.prefix = key.endsWith("/") ? key : key + "/";
+        this.batchEnabled = batchEnabled;
+    }
+
+    /** Deletes every object under the prefix using the configured delete 
strategy. */
+    void execute() throws IOException {
+        String continuationToken = null;
+
+        do {
+            final ListObjectsV2Response response = listPage(continuationToken);
+            final List<String> pageKeys = extractKeys(response);
+
+            if (!pageKeys.isEmpty()) {
+                deletePage(pageKeys);
+            }
+
+            continuationToken = response.nextContinuationToken();
+        } while (continuationToken != null);
+    }
+
+    private ListObjectsV2Response listPage(String continuationToken) {
+        final ListObjectsV2Request.Builder requestBuilder =
+                
ListObjectsV2Request.builder().bucket(bucketName).prefix(prefix).maxKeys(PAGE_SIZE);
+        if (continuationToken != null) {
+            requestBuilder.continuationToken(continuationToken);
+        }
+        return s3Client.listObjectsV2(requestBuilder.build());
+    }
+
+    private List<String> extractKeys(ListObjectsV2Response response) {
+        final List<String> keys = new ArrayList<>(response.contents().size());
+        for (S3Object s3Object : response.contents()) {
+            keys.add(s3Object.key());
+        }
+        return keys;
+    }
+
+    private void deletePage(List<String> keys) throws IOException {
+        if (batchEnabled) {
+            LOG.debug(
+                    "Deleting {} object(s) under prefix {} using batched 
DeleteObjects",
+                    keys.size(),
+                    prefix);
+            deleteBatch(keys);
+        } else {
+            LOG.debug(
+                    "Deleting {} object(s) under prefix {} using individual 
DeleteObject calls "
+                            + "(delete batching disabled)",
+                    keys.size(),
+                    prefix);
+            deleteIndividually(keys);
+        }
+    }
+
+    private void deleteIndividually(List<String> keys) {
+        for (String key : keys) {
+            final DeleteObjectRequest request =
+                    
DeleteObjectRequest.builder().bucket(bucketName).key(key).build();
+            s3Client.deleteObject(request);
+        }
+    }
+
+    private void deleteBatch(List<String> keys) throws IOException {
+        final List<ObjectIdentifier> objectIdentifiers = new 
ArrayList<>(keys.size());
+        for (String key : keys) {
+            objectIdentifiers.add(ObjectIdentifier.builder().key(key).build());
+        }
+
+        final DeleteObjectsRequest request =
+                DeleteObjectsRequest.builder()
+                        .bucket(bucketName)
+                        
.delete(Delete.builder().objects(objectIdentifiers).build())
+                        .build();
+
+        LOG.debug("Issuing batch DeleteObjects request for {} key(s)", 
keys.size());
+        final DeleteObjectsResponse response = s3Client.deleteObjects(request);
+        if (response.hasErrors() && !response.errors().isEmpty()) {
+            final StringBuilder errorMessage = new StringBuilder("Failed to 
delete objects: ");
+            for (S3Error error : response.errors()) {
+                errorMessage
+                        .append(error.key())
+                        .append(" (")
+                        .append(error.code())
+                        .append(": ")
+                        .append(error.message())
+                        .append("); ");
+            }
+            throw new IOException(errorMessage.toString());
+        }
+    }
+}
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3BucketConfig.java
 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3BucketConfig.java
index 0625254cd33..13f52e8cec9 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3BucketConfig.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/main/java/org/apache/flink/fs/s3native/S3BucketConfig.java
@@ -51,6 +51,7 @@ final class S3BucketConfig {
     @Nullable private final String assumeRoleSessionName;
     @Nullable private final Integer assumeRoleSessionDurationSeconds;
     @Nullable private final String credentialsProvider;
+    @Nullable private final Boolean deleteBatchEnabled;
 
     private S3BucketConfig(Builder builder) {
         this.bucketName = builder.bucketName;
@@ -66,6 +67,7 @@ final class S3BucketConfig {
         this.assumeRoleSessionName = builder.assumeRoleSessionName;
         this.assumeRoleSessionDurationSeconds = 
builder.assumeRoleSessionDurationSeconds;
         this.credentialsProvider = builder.credentialsProvider;
+        this.deleteBatchEnabled = builder.deleteBatchEnabled;
     }
 
     String getBucketName() {
@@ -132,6 +134,11 @@ final class S3BucketConfig {
         return credentialsProvider;
     }
 
+    @Nullable
+    Boolean getDeleteBatchEnabled() {
+        return deleteBatchEnabled;
+    }
+
     boolean hasAnyOverride() {
         return region != null
                 || endpoint != null
@@ -144,7 +151,8 @@ final class S3BucketConfig {
                 || assumeRoleExternalId != null
                 || assumeRoleSessionName != null
                 || assumeRoleSessionDurationSeconds != null
-                || credentialsProvider != null;
+                || credentialsProvider != null
+                || deleteBatchEnabled != null;
     }
 
     @Override
@@ -169,7 +177,8 @@ final class S3BucketConfig {
                 && Objects.equals(assumeRoleSessionName, 
that.assumeRoleSessionName)
                 && Objects.equals(
                         assumeRoleSessionDurationSeconds, 
that.assumeRoleSessionDurationSeconds)
-                && Objects.equals(credentialsProvider, 
that.credentialsProvider);
+                && Objects.equals(credentialsProvider, 
that.credentialsProvider)
+                && Objects.equals(deleteBatchEnabled, that.deleteBatchEnabled);
     }
 
     @Override
@@ -187,7 +196,8 @@ final class S3BucketConfig {
                 assumeRoleExternalId,
                 assumeRoleSessionName,
                 assumeRoleSessionDurationSeconds,
-                credentialsProvider);
+                credentialsProvider,
+                deleteBatchEnabled);
     }
 
     @Override
@@ -228,6 +238,9 @@ final class S3BucketConfig {
         if (credentialsProvider != null) {
             sb.append(", 
credentialsProvider='").append(credentialsProvider).append("'");
         }
+        if (deleteBatchEnabled != null) {
+            sb.append(", deleteBatchEnabled=").append(deleteBatchEnabled);
+        }
         sb.append('}');
         return sb.toString();
     }
@@ -253,6 +266,7 @@ final class S3BucketConfig {
         @Nullable private String assumeRoleSessionName;
         @Nullable private Integer assumeRoleSessionDurationSeconds;
         @Nullable private String credentialsProvider;
+        @Nullable private Boolean deleteBatchEnabled;
 
         private Builder(String bucketName) {
             this.bucketName = bucketName;
@@ -322,6 +336,11 @@ final class S3BucketConfig {
             return this;
         }
 
+        Builder deleteBatchEnabled(boolean deleteBatchEnabled) {
+            this.deleteBatchEnabled = deleteBatchEnabled;
+            return this;
+        }
+
         S3BucketConfig build() {
             boolean hasAccessKey = 
!StringUtils.isNullOrWhitespaceOnly(accessKey);
             boolean hasSecretKey = 
!StringUtils.isNullOrWhitespaceOnly(secretKey);
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/BucketConfigProviderTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/BucketConfigProviderTest.java
index 97921ff3175..be39aed2764 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/BucketConfigProviderTest.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/BucketConfigProviderTest.java
@@ -29,7 +29,7 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 /** Tests for {@link BucketConfigProvider}. */
 class BucketConfigProviderTest {
 
-    /** One test exercises all 11 known properties on a single bucket. */
+    /** One test exercises all 12 known properties on a single bucket. */
     @Test
     void testParsesAllKnownPropertiesForSingleBucket() {
         Configuration config = new Configuration();
@@ -52,6 +52,7 @@ class BucketConfigProviderTest {
         config.setString(
                 "s3.bucket.my-bucket.aws.credentials.provider",
                 
"software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider");
+        config.setString("s3.bucket.my-bucket.delete.batch.enabled", "false");
 
         BucketConfigProvider provider = new BucketConfigProvider(config);
 
@@ -73,6 +74,7 @@ class BucketConfigProviderTest {
         
assertThat(bucket.getAssumeRoleSessionDurationSeconds()).isEqualTo(7200);
         assertThat(bucket.getCredentialsProvider())
                 
.isEqualTo("software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider");
+        assertThat(bucket.getDeleteBatchEnabled()).isFalse();
     }
 
     @Test
@@ -218,6 +220,17 @@ class BucketConfigProviderTest {
                 .hasMessageContaining("Invalid path-style-access");
     }
 
+    @Test
+    void testInvalidDeleteBatchEnabledThrowsException() {
+        Configuration config = new Configuration();
+        config.setString("s3.bucket.my-bucket.delete.batch.enabled", "treu");
+        config.setString("s3.bucket.my-bucket.region", "us-east-1");
+
+        assertThatThrownBy(() -> new BucketConfigProvider(config))
+                .isInstanceOf(IllegalConfigurationException.class)
+                .hasMessageContaining("Invalid delete.batch.enabled");
+    }
+
     @Test
     void testUnrecognizedBucketPropertyIsIgnoredWithoutThrow() {
         Configuration config = new Configuration();
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
index 5ed2edd1614..2d927d4fde7 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemFactoryTest.java
@@ -218,6 +218,29 @@ class NativeS3FileSystemFactoryTest {
                 .isEqualTo(256 * 1024);
     }
 
+    // --- Delete batching ---
+
+    @Test
+    void testDeleteBatchEnabledDefaultIsTrue() throws Exception {
+        assertThat(createFs(baseConfig()).isDeleteBatchEnabled()).isTrue();
+    }
+
+    @Test
+    void testDeleteBatchEnabledExplicitlyDisabled() throws Exception {
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.DELETE_BATCH_ENABLED, false);
+        assertThat(createFs(config).isDeleteBatchEnabled()).isFalse();
+    }
+
+    @Test
+    void testDeleteBatchEnabledBucketOverridesGlobal() throws Exception {
+        // Global: true; bucket: false → bucket wins
+        Configuration config = baseConfig();
+        config.set(NativeS3FileSystemFactory.DELETE_BATCH_ENABLED, true);
+        config.setString("s3.bucket.test-bucket.delete.batch.enabled", 
"false");
+        assertThat(createFs(config).isDeleteBatchEnabled()).isFalse();
+    }
+
     // --- Max connections ---
 
     @Test
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemITCase.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemITCase.java
index 5b10a9319d2..ac708c69400 100644
--- 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemITCase.java
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3FileSystemITCase.java
@@ -32,6 +32,8 @@ import org.apache.flink.core.testutils.TestContainerExtension;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
@@ -109,6 +111,31 @@ class NativeS3FileSystemITCase {
                 .doesNotThrowAnyException();
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    void testRecursiveDeleteManyFiles(boolean batchingEnabled) throws 
Exception {
+        final Configuration config = new Configuration();
+        container().setS3ConfigOptions(config);
+        config.set(NativeS3FileSystemFactory.DELETE_BATCH_ENABLED, 
batchingEnabled);
+
+        final NativeS3FileSystemFactory factory = new 
NativeS3FileSystemFactory();
+        factory.configure(config);
+        final FileSystem targetFs = factory.create(URI.create(bucketUri + 
"/"));
+
+        final String dir = "bulk-delete-" + UUID.randomUUID();
+        final int numFiles = 25;
+        for (int i = 0; i < numFiles; i++) {
+            write(
+                    targetFs,
+                    path(dir + "/file-" + i + ".txt"),
+                    ("data-" + i).getBytes(StandardCharsets.UTF_8));
+        }
+
+        assertThat(targetFs.listStatus(path(dir))).hasSize(numFiles);
+        assertThat(targetFs.delete(path(dir), true)).isTrue();
+        assertThat(targetFs.exists(path(dir))).isFalse();
+    }
+
     @Test
     void testRecoverableWriterMultipartCommit() throws Exception {
         final Path file = path("recoverable-" + UUID.randomUUID() + ".bin");
@@ -132,7 +159,11 @@ class NativeS3FileSystemITCase {
     }
 
     private static void write(Path path, byte[] data) throws Exception {
-        try (FSDataOutputStream out = fs.create(path, 
FileSystem.WriteMode.OVERWRITE)) {
+        write(fs, path, data);
+    }
+
+    private static void write(FileSystem targetFs, Path path, byte[] data) 
throws Exception {
+        try (FSDataOutputStream out = targetFs.create(path, 
FileSystem.WriteMode.OVERWRITE)) {
             out.write(data);
         }
     }
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3RecursiveDeleteTest.java
 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3RecursiveDeleteTest.java
new file mode 100644
index 00000000000..15fbf53748c
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/java/org/apache/flink/fs/s3native/NativeS3RecursiveDeleteTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.flink.fs.s3native;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectResponse;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
+import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
+import software.amazon.awssdk.services.s3.model.ObjectIdentifier;
+import software.amazon.awssdk.services.s3.model.S3Error;
+import software.amazon.awssdk.services.s3.model.S3Object;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link NativeS3RecursiveDelete}. */
+class NativeS3RecursiveDeleteTest {
+
+    private static final String BUCKET = "test-bucket";
+
+    @Test
+    void batchingEnabledIssuesOneDeleteObjectsCallAndNoDeleteObjectCalls() 
throws Exception {
+        try (RecordingS3Client client = new RecordingS3Client(keys(5), 1000)) {
+            new NativeS3RecursiveDelete(client, BUCKET, "dir", true).execute();
+
+            assertThat(client.deleteObjectsRequests).hasSize(1);
+            assertThat(client.deleteObjectsRequests.get(0).delete().objects())
+                    .extracting(ObjectIdentifier::key)
+                    .containsExactlyElementsOf(keys(5));
+            assertThat(client.deleteObjectRequests).isEmpty();
+        }
+    }
+
+    @Test
+    void 
batchingDisabledIssuesOneDeleteObjectCallPerKeyAndNoDeleteObjectsCalls() throws 
Exception {
+        try (RecordingS3Client client = new RecordingS3Client(keys(5), 1000)) {
+            new NativeS3RecursiveDelete(client, BUCKET, "dir", 
false).execute();
+
+            assertThat(client.deleteObjectRequests)
+                    .extracting(DeleteObjectRequest::key)
+                    .containsExactlyElementsOf(keys(5));
+            assertThat(client.deleteObjectsRequests).isEmpty();
+        }
+    }
+
+    @Test
+    void moreThanPageSizeKeysAreSplitAcrossMultipleDeleteObjectsCalls() throws 
Exception {
+        // Small page size to make pagination and per-page batching observable 
without
+        // constructing 1000+ keys.
+        try (RecordingS3Client client = new RecordingS3Client(keys(7), 3)) {
+            new NativeS3RecursiveDelete(client, BUCKET, "dir", true).execute();
+
+            // 7 keys at 3 per listing page -> 3 DeleteObjects calls (3, 3, 1 
keys), each within
+            // the page size, and never holding more than one page of keys at 
a time.
+            assertThat(client.deleteObjectsRequests).hasSize(3);
+            assertThat(
+                            client.deleteObjectsRequests.stream()
+                                    .flatMap(r -> 
r.delete().objects().stream())
+                                    .map(ObjectIdentifier::key)
+                                    .collect(Collectors.toList()))
+                    .containsExactlyElementsOf(keys(7));
+            assertThat(client.deleteObjectsRequests)
+                    .allSatisfy(r -> 
assertThat(r.delete().objects()).hasSizeLessThanOrEqualTo(3));
+        }
+    }
+
+    @Test
+    void partialBatchDeleteFailureThrowsIOException() throws Exception {
+        try (RecordingS3Client client = new RecordingS3Client(keys(3), 1000)) {
+            client.failKeyWith(
+                    keys(3).get(1), 
S3Error.builder().code("AccessDenied").message("nope").build());
+
+            assertThatThrownBy(
+                            () ->
+                                    new NativeS3RecursiveDelete(client, 
BUCKET, "dir", true)
+                                            .execute())
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining(keys(3).get(1))
+                    .hasMessageContaining("AccessDenied");
+        }
+    }
+
+    private static List<String> keys(int count) {
+        return IntStream.range(0, count)
+                .mapToObj(i -> "dir/file-" + i)
+                .collect(Collectors.toList());
+    }
+
+    /** Records every {@code list}/{@code delete} request issued against a 
fixed key set. */
+    private static final class RecordingS3Client implements S3Client {
+        private final List<String> allKeys;
+        private final int pageSize;
+        private final Map<String, S3Error> failuresByKey = new HashMap<>();
+
+        final List<DeleteObjectsRequest> deleteObjectsRequests = new 
ArrayList<>();
+        final List<DeleteObjectRequest> deleteObjectRequests = new 
ArrayList<>();
+
+        RecordingS3Client(List<String> allKeys, int pageSize) {
+            this.allKeys = allKeys;
+            this.pageSize = pageSize;
+        }
+
+        void failKeyWith(String key, S3Error error) {
+            failuresByKey.put(key, error);
+        }
+
+        @Override
+        public ListObjectsV2Response listObjectsV2(ListObjectsV2Request 
request) {
+            int start =
+                    request.continuationToken() == null
+                            ? 0
+                            : Integer.parseInt(request.continuationToken());
+            int end = Math.min(start + pageSize, allKeys.size());
+
+            List<S3Object> page =
+                    allKeys.subList(start, end).stream()
+                            .map(k -> S3Object.builder().key(k).build())
+                            .collect(Collectors.toList());
+
+            ListObjectsV2Response.Builder builder = 
ListObjectsV2Response.builder().contents(page);
+            if (end < allKeys.size()) {
+                
builder.nextContinuationToken(String.valueOf(end)).isTruncated(true);
+            }
+            return builder.build();
+        }
+
+        @Override
+        public DeleteObjectsResponse deleteObjects(DeleteObjectsRequest 
request) {
+            deleteObjectsRequests.add(request);
+            List<S3Error> errors = new ArrayList<>();
+            for (ObjectIdentifier id : request.delete().objects()) {
+                S3Error error = failuresByKey.get(id.key());
+                if (error != null) {
+                    errors.add(error.toBuilder().key(id.key()).build());
+                }
+            }
+            return DeleteObjectsResponse.builder().errors(errors).build();
+        }
+
+        @Override
+        public DeleteObjectResponse deleteObject(DeleteObjectRequest request) {
+            deleteObjectRequests.add(request);
+            return DeleteObjectResponse.builder().build();
+        }
+
+        @Override
+        public String serviceName() {
+            return "s3";
+        }
+
+        @Override
+        public void close() {}
+    }
+}
diff --git 
a/flink-filesystems/flink-s3-fs-native/src/test/resources/log4j2-test.properties
 
b/flink-filesystems/flink-s3-fs-native/src/test/resources/log4j2-test.properties
new file mode 100644
index 00000000000..835c2ec9a3d
--- /dev/null
+++ 
b/flink-filesystems/flink-s3-fs-native/src/test/resources/log4j2-test.properties
@@ -0,0 +1,28 @@
+################################################################################
+#  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.
+################################################################################
+
+# Set root logger level to OFF to not flood build logs
+# set manually to INFO for debugging purposes
+rootLogger.level = OFF
+rootLogger.appenderRef.test.ref = TestLogger
+
+appender.testlogger.name = TestLogger
+appender.testlogger.type = CONSOLE
+appender.testlogger.target = SYSTEM_ERR
+appender.testlogger.layout.type = PatternLayout
+appender.testlogger.layout.pattern = %-4r [%t] %-5p %c %x - %m%n

Reply via email to