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

ajfabbri pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/hadoop.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 85c23372402 HADOOP-19862: S3A: introduce configurable shared thread 
pool for AWS SDK clients (#8426)
85c23372402 is described below

commit 85c233724020e27ca9e5ef69fec2fe6de1d592a1
Author: Konstantin Bereznyakov <[email protected]>
AuthorDate: Tue Jul 7 14:29:21 2026 -0700

    HADOOP-19862: S3A: introduce configurable shared thread pool for AWS SDK 
clients (#8426)
    
    * S3A: introduce configurable shared thread pool for AWS SDK clients
    * missed the need to upgrade new test code to JUnit 5
    * addressing addiitional PR feedback (mainly test code)
---
 .../src/main/resources/core-default.xml            | 119 ++++++
 .../org/apache/hadoop/test/GenericTestUtils.java   |  22 +
 .../java/org/apache/hadoop/fs/s3a/Constants.java   |  87 ++++
 .../hadoop/fs/s3a/DefaultS3ClientFactory.java      |  72 +++-
 .../hadoop/fs/s3a/auth/STSClientFactory.java       |  30 ++
 .../fs/s3a/impl/EncryptionS3ClientFactory.java     |  33 ++
 .../fs/s3a/impl/LazySharedThreadPoolHolder.java    | 142 +++++++
 .../fs/s3a/ITestS3ASharedThreadPoolDisabled.java   |  96 +++++
 .../fs/s3a/ITestS3ASharedThreadPoolEnabled.java    | 106 +++++
 .../org/apache/hadoop/fs/s3a/S3ATestUtils.java     |  50 +++
 .../hadoop/fs/s3a/TestSharedScheduledExecutor.java | 450 +++++++++++++++++++++
 11 files changed, 1200 insertions(+), 7 deletions(-)

diff --git 
a/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml 
b/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml
index a07460dc308..2609b1d0f4b 100644
--- a/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml
+++ b/hadoop-common-project/hadoop-common/src/main/resources/core-default.xml
@@ -2258,6 +2258,125 @@ The switch to turn S3A auditing on or off.
   </description>
 </property>
 
+<!--
+AWS SDK client shared thread pool settings. When enabled, AWS SDK clients of a
+given type share a single scheduled thread pool instead of each client creating
+its own. This bounds thread growth when many S3A filesystem instances are
+created. Each pool is opt-in and disabled by default.
+
+The default size of 5 matches the per-client default the AWS SDK would 
otherwise
+use (Executors.newScheduledThreadPool(5)). Unlike that SDK default, which keeps
+its core threads for the client's lifetime, the shared pool reaps threads that
+are idle beyond the keepalive window.
+-->
+<property>
+  <name>fs.s3a.aws.s3.client.shared.threadpool.enabled</name>
+  <value>false</value>
+  <description>
+    Enable a shared scheduled thread pool for the AWS S3 synchronous client.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.s3.client.shared.threadpool.size</name>
+  <value>5</value>
+  <description>
+    Core pool size of the shared thread pool for the AWS S3 synchronous client.
+    Only used when fs.s3a.aws.s3.client.shared.threadpool.enabled is true.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.s3.client.shared.threadpool.keepalive.seconds</name>
+  <value>60</value>
+  <description>
+    Keepalive time in seconds for idle threads in the shared thread pool for 
the
+    AWS S3 synchronous client. Only used when
+    fs.s3a.aws.s3.client.shared.threadpool.enabled is true.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.s3.async.client.shared.threadpool.enabled</name>
+  <value>false</value>
+  <description>
+    Enable a shared scheduled thread pool for the AWS S3 asynchronous client.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.s3.async.client.shared.threadpool.size</name>
+  <value>5</value>
+  <description>
+    Core pool size of the shared thread pool for the AWS S3 asynchronous 
client.
+    Only used when fs.s3a.aws.s3.async.client.shared.threadpool.enabled is 
true.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.s3.async.client.shared.threadpool.keepalive.seconds</name>
+  <value>60</value>
+  <description>
+    Keepalive time in seconds for idle threads in the shared thread pool for 
the
+    AWS S3 asynchronous client. Only used when
+    fs.s3a.aws.s3.async.client.shared.threadpool.enabled is true.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.sts.client.shared.threadpool.enabled</name>
+  <value>false</value>
+  <description>
+    Enable a shared scheduled thread pool for the AWS STS client.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.sts.client.shared.threadpool.size</name>
+  <value>5</value>
+  <description>
+    Core pool size of the shared thread pool for the AWS STS client.
+    Only used when fs.s3a.aws.sts.client.shared.threadpool.enabled is true.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.sts.client.shared.threadpool.keepalive.seconds</name>
+  <value>60</value>
+  <description>
+    Keepalive time in seconds for idle threads in the shared thread pool for 
the
+    AWS STS client. Only used when
+    fs.s3a.aws.sts.client.shared.threadpool.enabled is true.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.kms.client.shared.threadpool.enabled</name>
+  <value>false</value>
+  <description>
+    Enable a shared scheduled thread pool for the AWS KMS client.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.kms.client.shared.threadpool.size</name>
+  <value>5</value>
+  <description>
+    Core pool size of the shared thread pool for the AWS KMS client.
+    Only used when fs.s3a.aws.kms.client.shared.threadpool.enabled is true.
+  </description>
+</property>
+
+<property>
+  <name>fs.s3a.aws.kms.client.shared.threadpool.keepalive.seconds</name>
+  <value>60</value>
+  <description>
+    Keepalive time in seconds for idle threads in the shared thread pool for 
the
+    AWS KMS client. Only used when
+    fs.s3a.aws.kms.client.shared.threadpool.enabled is true.
+  </description>
+</property>
+
   <!-- Azure file system properties -->
 <property>
   <name>fs.AbstractFileSystem.wasb.impl</name>
diff --git 
a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/test/GenericTestUtils.java
 
b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/test/GenericTestUtils.java
index 65c6c8e2cd5..f20a5604978 100644
--- 
a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/test/GenericTestUtils.java
+++ 
b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/test/GenericTestUtils.java
@@ -738,6 +738,28 @@ public static boolean anyThreadMatching(Pattern pattern) {
     return false;
   }
 
+  /**
+   * Count the live threads whose name matches the given pattern.
+   * @param pattern a Pattern object used to match thread names
+   * @return the number of live threads whose name matches the pattern
+   */
+  public static int countThreadsMatching(Pattern pattern) {
+    ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
+
+    ThreadInfo[] infos =
+        threadBean.getThreadInfo(threadBean.getAllThreadIds(), 20);
+    int count = 0;
+    for (ThreadInfo info : infos) {
+      if (info == null) {
+        continue;
+      }
+      if (pattern.matcher(info.getThreadName()).matches()) {
+        count++;
+      }
+    }
+    return count;
+  }
+
   /**
    * Assert that there are no threads running whose name matches the
    * given regular expression.
diff --git 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/Constants.java 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/Constants.java
index 271e9302a3d..a26eefa0179 100644
--- 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/Constants.java
+++ 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/Constants.java
@@ -1948,4 +1948,91 @@ private Constants() {
    *      AWS S3 PutObject API Documentation</a>
    */
   public static final String IF_NONE_MATCH_STAR = "*";
+
+  // ==================== AWS Client Shared Thread Pool Configuration 
===========
+  // These settings control whether AWS SDK clients share a thread pool
+  // instead of each client creating its own. This prevents thread leaks
+  // when many S3A filesystem instances are created.
+
+  /**
+   * Default thread pool size for AWS client shared thread pools: {@value}.
+   */
+  public static final int AWS_CLIENT_SHARED_THREADPOOL_SIZE_DEFAULT = 5;
+
+  /**
+   * Default keepalive in seconds for AWS client shared thread pool: {@value}.
+   */
+  public static final int AWS_CLIENT_SHARED_THREADPOOL_KEEPALIVE_DEFAULT = 60;
+
+  /**
+   * Enable shared thread pool for AWS S3 sync client: {@value}.
+   */
+  public static final String AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED =
+      "fs.s3a.aws.s3.client.shared.threadpool.enabled";
+
+  /**
+   * Thread pool size for AWS S3 sync client shared thread pool: {@value}.
+   */
+  public static final String AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE =
+      "fs.s3a.aws.s3.client.shared.threadpool.size";
+
+  /**
+   * Keepalive in seconds for AWS S3 sync client shared thread pool: {@value}.
+   */
+  public static final String AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE =
+      "fs.s3a.aws.s3.client.shared.threadpool.keepalive.seconds";
+
+  /**
+   * Enable shared thread pool for AWS S3 async client: {@value}.
+   */
+  public static final String AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED =
+      "fs.s3a.aws.s3.async.client.shared.threadpool.enabled";
+
+  /**
+   * Thread pool size for AWS S3 async client shared thread pool: {@value}.
+   */
+  public static final String AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE =
+      "fs.s3a.aws.s3.async.client.shared.threadpool.size";
+
+  /**
+   * Keepalive in seconds for AWS S3 async client shared thread pool: {@value}.
+   */
+  public static final String AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_KEEPALIVE =
+      "fs.s3a.aws.s3.async.client.shared.threadpool.keepalive.seconds";
+
+  /**
+   * Enable shared thread pool for AWS STS client: {@value}.
+   */
+  public static final String AWS_STS_CLIENT_SHARED_THREADPOOL_ENABLED =
+      "fs.s3a.aws.sts.client.shared.threadpool.enabled";
+
+  /**
+   * Thread pool size for AWS STS client shared thread pool: {@value}.
+   */
+  public static final String AWS_STS_CLIENT_SHARED_THREADPOOL_SIZE =
+      "fs.s3a.aws.sts.client.shared.threadpool.size";
+
+  /**
+   * Keepalive in seconds for AWS STS client shared thread pool: {@value}.
+   */
+  public static final String AWS_STS_CLIENT_SHARED_THREADPOOL_KEEPALIVE =
+      "fs.s3a.aws.sts.client.shared.threadpool.keepalive.seconds";
+
+  /**
+   * Enable shared thread pool for AWS KMS client: {@value}.
+   */
+  public static final String AWS_KMS_CLIENT_SHARED_THREADPOOL_ENABLED =
+      "fs.s3a.aws.kms.client.shared.threadpool.enabled";
+
+  /**
+   * Thread pool size for AWS KMS client shared thread pool: {@value}.
+   */
+  public static final String AWS_KMS_CLIENT_SHARED_THREADPOOL_SIZE =
+      "fs.s3a.aws.kms.client.shared.threadpool.size";
+
+  /**
+   * Keepalive in seconds for AWS KMS client shared thread pool: {@value}.
+   */
+  public static final String AWS_KMS_CLIENT_SHARED_THREADPOOL_KEEPALIVE =
+      "fs.s3a.aws.kms.client.shared.threadpool.keepalive.seconds";
 }
diff --git 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/DefaultS3ClientFactory.java
 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/DefaultS3ClientFactory.java
index 41e904ec9de..c8446c38c24 100644
--- 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/DefaultS3ClientFactory.java
+++ 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/DefaultS3ClientFactory.java
@@ -21,11 +21,13 @@
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import org.apache.hadoop.classification.VisibleForTesting;
 import org.apache.hadoop.fs.s3a.impl.AWSClientConfig;
+import org.apache.hadoop.fs.s3a.impl.LazySharedThreadPoolHolder;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -63,17 +65,23 @@
 import static org.apache.hadoop.fs.s3a.Constants.AWS_REGION;
 import static org.apache.hadoop.fs.s3a.Constants.AWS_S3_ACCESS_GRANTS_ENABLED;
 import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ACCESS_GRANTS_FALLBACK_TO_IAM_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE;
 import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CROSS_REGION_ACCESS_ENABLED;
 import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CROSS_REGION_ACCESS_ENABLED_DEFAULT;
 import static org.apache.hadoop.fs.s3a.Constants.AWS_S3_DEFAULT_REGION;
+import static org.apache.hadoop.fs.s3a.Constants.AWS_SERVICE_IDENTIFIER_S3;
 import static org.apache.hadoop.fs.s3a.Constants.CENTRAL_ENDPOINT;
+import static org.apache.hadoop.fs.s3a.Constants.DEFAULT_SECURE_CONNECTIONS;
 import static org.apache.hadoop.fs.s3a.Constants.FIPS_ENDPOINT;
 import static org.apache.hadoop.fs.s3a.Constants.HTTP_SIGNER_CLASS_NAME;
 import static org.apache.hadoop.fs.s3a.Constants.HTTP_SIGNER_ENABLED;
 import static org.apache.hadoop.fs.s3a.Constants.HTTP_SIGNER_ENABLED_DEFAULT;
-import static org.apache.hadoop.fs.s3a.Constants.DEFAULT_SECURE_CONNECTIONS;
 import static org.apache.hadoop.fs.s3a.Constants.SECURE_CONNECTIONS;
-import static org.apache.hadoop.fs.s3a.Constants.AWS_SERVICE_IDENTIFIER_S3;
 import static org.apache.hadoop.fs.s3a.auth.SignerFactory.createHttpSigner;
 import static org.apache.hadoop.fs.s3a.impl.AWSHeaders.REQUESTER_PAYS_HEADER;
 import static 
org.apache.hadoop.fs.s3a.impl.InternalConstants.AUTH_SCHEME_AWS_SIGV_4;
@@ -97,6 +105,46 @@ public class DefaultS3ClientFactory extends Configured
   private static final Pattern VPC_ENDPOINT_PATTERN =
           
Pattern.compile("^(?:.+\\.)?([a-z0-9-]+)\\.vpce\\.amazonaws\\.(?:com|com\\.cn)$");
 
+  /**
+   * Shared executor for S3 sync clients.
+   */
+  private static final LazySharedThreadPoolHolder S3_SYNC_EXECUTOR =
+      new LazySharedThreadPoolHolder(
+          AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED,
+          AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE,
+          AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+          "s3a-s3-sync-scheduler");
+
+  /**
+   * Shared executor for S3 async clients.
+   */
+  private static final LazySharedThreadPoolHolder S3_ASYNC_EXECUTOR =
+      new LazySharedThreadPoolHolder(
+          AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED,
+          AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE,
+          AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+          "s3a-s3-async-scheduler");
+
+  /**
+   * Get the shared executor holder for S3 sync clients.
+   * This is for testing only.
+   * @return the holder
+   */
+  @VisibleForTesting
+  static LazySharedThreadPoolHolder s3SyncExecutorHolder() {
+    return S3_SYNC_EXECUTOR;
+  }
+
+  /**
+   * Get the shared executor holder for S3 async clients.
+   * This is for testing only.
+   * @return the holder
+   */
+  @VisibleForTesting
+  static LazySharedThreadPoolHolder s3AsyncExecutorHolder() {
+    return S3_ASYNC_EXECUTOR;
+  }
+
   /**
    * Subclasses refer to this.
    */
@@ -235,8 +283,10 @@ private <BuilderT extends S3BaseClientBuilder<BuilderT, 
ClientT>, ClientT> Build
         .pathStyleAccessEnabled(parameters.isPathStyleAccess())
         .build();
 
-    final ClientOverrideConfiguration.Builder override =
-        createClientOverrideConfiguration(parameters, conf);
+    final ClientOverrideConfiguration.Builder override = 
createClientOverrideConfiguration(
+        parameters,
+        conf,
+        builder instanceof S3AsyncClientBuilder);
 
     S3BaseClientBuilder<BuilderT, ClientT> s3BaseClientBuilder = builder
         .overrideConfiguration(override.build())
@@ -265,13 +315,14 @@ private <BuilderT extends S3BaseClientBuilder<BuilderT, 
ClientT>, ClientT> Build
    * Create an override configuration for an S3 client.
    * @param parameters parameter object
    * @param conf configuration object
-   * @throws IOException any IOE raised, or translated exception
-   * @throws RuntimeException some failures creating an http signer
+   * @param isAsync true for async client, false for sync client
    * @return the override configuration
    * @throws IOException any IOE raised, or translated exception
+   * @throws RuntimeException some failures creating an http signer
    */
   protected ClientOverrideConfiguration.Builder 
createClientOverrideConfiguration(
-      S3ClientCreationParameters parameters, Configuration conf) throws 
IOException {
+      S3ClientCreationParameters parameters, Configuration conf, boolean 
isAsync)
+      throws IOException {
     final ClientOverrideConfiguration.Builder clientOverrideConfigBuilder =
         AWSClientConfig.createClientConfigBuilder(conf, 
AWS_SERVICE_IDENTIFIER_S3);
 
@@ -302,6 +353,13 @@ protected ClientOverrideConfiguration.Builder 
createClientOverrideConfiguration(
     final RetryPolicy.Builder retryPolicyBuilder = 
AWSClientConfig.createRetryPolicyBuilder(conf);
     clientOverrideConfigBuilder.retryPolicy(retryPolicyBuilder.build());
 
+    ScheduledExecutorService executor = isAsync
+        ? S3_ASYNC_EXECUTOR.get(conf)
+        : S3_SYNC_EXECUTOR.get(conf);
+    if (executor != null) {
+      clientOverrideConfigBuilder.scheduledExecutorService(executor);
+    }
+
     return clientOverrideConfigBuilder;
   }
 
diff --git 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/auth/STSClientFactory.java
 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/auth/STSClientFactory.java
index ab151744969..56d9056f920 100644
--- 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/auth/STSClientFactory.java
+++ 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/auth/STSClientFactory.java
@@ -22,6 +22,7 @@
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
 import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
@@ -35,6 +36,7 @@
 import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
 import software.amazon.awssdk.services.sts.model.Credentials;
 import software.amazon.awssdk.services.sts.model.GetSessionTokenRequest;
+import org.apache.hadoop.fs.s3a.impl.LazySharedThreadPoolHolder;
 import org.apache.hadoop.fs.s3a.impl.AWSClientConfig;
 import org.apache.hadoop.util.Preconditions;
 
@@ -44,6 +46,7 @@
 
 import org.apache.hadoop.classification.InterfaceAudience;
 import org.apache.hadoop.classification.InterfaceStability;
+import org.apache.hadoop.classification.VisibleForTesting;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.s3a.Invoker;
 import org.apache.hadoop.fs.s3a.Retries;
@@ -51,6 +54,9 @@
 import static org.apache.commons.lang3.StringUtils.isEmpty;
 import static org.apache.commons.lang3.StringUtils.isNotEmpty;
 import static org.apache.hadoop.fs.s3a.Constants.AWS_SERVICE_IDENTIFIER_STS;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_STS_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_STS_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_STS_CLIENT_SHARED_THREADPOOL_SIZE;
 import static org.apache.hadoop.fs.s3a.auth.delegation.DelegationConstants.*;
 
 /**
@@ -63,6 +69,26 @@ public class STSClientFactory {
   private static final Logger LOG =
       LoggerFactory.getLogger(STSClientFactory.class);
 
+  /**
+   * Shared executor for STS clients.
+   */
+  private static final LazySharedThreadPoolHolder STS_EXECUTOR =
+      new LazySharedThreadPoolHolder(
+          AWS_STS_CLIENT_SHARED_THREADPOOL_ENABLED,
+          AWS_STS_CLIENT_SHARED_THREADPOOL_SIZE,
+          AWS_STS_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+          "s3a-sts-scheduler");
+
+  /**
+   * Get the shared executor holder for STS clients.
+   * This is for testing only.
+   * @return the holder
+   */
+  @VisibleForTesting
+  public static LazySharedThreadPoolHolder stsExecutorHolder() {
+    return STS_EXECUTOR;
+  }
+
   /**
    * Create the builder ready for any final configuration options.
    * Picks up connection settings from the Hadoop configuration, including
@@ -139,6 +165,10 @@ public static StsClientBuilder builder(final 
AwsCredentialsProvider credentials,
     final ProxyConfiguration proxyConfig = 
AWSClientConfig.createProxyConfiguration(conf, bucket);
 
     clientOverrideConfigBuilder.retryPolicy(retryPolicyBuilder.build());
+    ScheduledExecutorService executor = STS_EXECUTOR.get(conf);
+    if (executor != null) {
+      clientOverrideConfigBuilder.scheduledExecutorService(executor);
+    }
     httpClientBuilder.proxyConfiguration(proxyConfig);
 
     stsClientBuilder.httpClientBuilder(httpClientBuilder)
diff --git 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/impl/EncryptionS3ClientFactory.java
 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/impl/EncryptionS3ClientFactory.java
index 3c2756dfb05..381d560d367 100644
--- 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/impl/EncryptionS3ClientFactory.java
+++ 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/impl/EncryptionS3ClientFactory.java
@@ -20,7 +20,9 @@
 
 import java.io.IOException;
 import java.net.URI;
+import java.util.concurrent.ScheduledExecutorService;
 
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
 import software.amazon.awssdk.regions.Region;
 import software.amazon.awssdk.services.kms.KmsClient;
 import software.amazon.awssdk.services.kms.KmsClientBuilder;
@@ -33,12 +35,16 @@
 import software.amazon.encryption.s3.materials.Keyring;
 import software.amazon.encryption.s3.materials.KmsKeyring;
 
+import org.apache.hadoop.classification.VisibleForTesting;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.fs.s3a.DefaultS3ClientFactory;
 import org.apache.hadoop.util.Preconditions;
 import org.apache.hadoop.util.ReflectionUtils;
 import org.apache.hadoop.util.functional.LazyAtomicReference;
 
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_KMS_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_KMS_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_KMS_CLIENT_SHARED_THREADPOOL_SIZE;
 import static 
org.apache.hadoop.fs.s3a.impl.InstantiationIOException.unavailable;
 
 /**
@@ -46,6 +52,26 @@
  */
 public class EncryptionS3ClientFactory extends DefaultS3ClientFactory {
 
+  /**
+   * Shared executor for KMS clients.
+   */
+  private static final LazySharedThreadPoolHolder KMS_EXECUTOR =
+      new LazySharedThreadPoolHolder(
+          AWS_KMS_CLIENT_SHARED_THREADPOOL_ENABLED,
+          AWS_KMS_CLIENT_SHARED_THREADPOOL_SIZE,
+          AWS_KMS_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+          "s3a-kms-scheduler");
+
+  /**
+   * Get the shared executor holder for KMS clients.
+   * This is for testing only.
+   * @return the holder
+   */
+  @VisibleForTesting
+  public static LazySharedThreadPoolHolder kmsExecutorHolder() {
+    return KMS_EXECUTOR;
+  }
+
   /**
    * Encryption client class name.
    * value: {@value}
@@ -201,6 +227,13 @@ private S3Client 
createS3EncryptionClient(S3ClientCreationParameters parameters)
   private Keyring createKmsKeyring(S3ClientCreationParameters parameters,
       CSEMaterials cseMaterials) {
     KmsClientBuilder kmsClientBuilder = KmsClient.builder();
+    ScheduledExecutorService executor = 
KMS_EXECUTOR.get(cseMaterials.getConf());
+    if (executor != null) {
+      kmsClientBuilder.overrideConfiguration(
+          ClientOverrideConfiguration.builder()
+              .scheduledExecutorService(executor)
+              .build());
+    }
     if (parameters.getCredentialSet() != null) {
       kmsClientBuilder.credentialsProvider(parameters.getCredentialSet());
     }
diff --git 
a/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/impl/LazySharedThreadPoolHolder.java
 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/impl/LazySharedThreadPoolHolder.java
new file mode 100644
index 00000000000..acae2fecfc8
--- /dev/null
+++ 
b/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/impl/LazySharedThreadPoolHolder.java
@@ -0,0 +1,142 @@
+/*
+ * 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.hadoop.fs.s3a.impl;
+
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+import org.apache.hadoop.classification.VisibleForTesting;
+import org.apache.hadoop.conf.Configuration;
+
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_CLIENT_SHARED_THREADPOOL_KEEPALIVE_DEFAULT;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_CLIENT_SHARED_THREADPOOL_SIZE_DEFAULT;
+import static org.apache.hadoop.fs.s3a.S3AUtils.intOption;
+
+/**
+ * Holder for a lazily initialized shared ScheduledExecutorService.
+ */
[email protected]
[email protected]
+public class LazySharedThreadPoolHolder {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(LazySharedThreadPoolHolder.class);
+
+  private final String enabledKey;
+  private final String sizeKey;
+  private final String keepAliveKey;
+  private final String namePrefix;
+
+  private Optional<ScheduledExecutorService> executor;
+
+  /**
+   * Create a holder for a lazy shared thread pool.
+   * @param enabledKey config key to enable the shared pool
+   * @param sizeKey config key for pool size
+   * @param keepAliveKey config key for thread keep-alive in seconds
+   * @param namePrefix thread name prefix for debugging
+   */
+  public LazySharedThreadPoolHolder(String enabledKey, String sizeKey,
+      String keepAliveKey, String namePrefix) {
+    this.enabledKey = enabledKey;
+    this.sizeKey = sizeKey;
+    this.keepAliveKey = keepAliveKey;
+    this.namePrefix = namePrefix;
+  }
+
+  /**
+   * Get the config key controlling whether the shared pool is enabled.
+   * This is for testing only.
+   * @return the enabled config key
+   */
+  @VisibleForTesting
+  public String getEnabledKey() {
+    return enabledKey;
+  }
+
+  /**
+   * Get the config key controlling the pool size.
+   * This is for testing only.
+   * @return the size config key
+   */
+  @VisibleForTesting
+  public String getSizeKey() {
+    return sizeKey;
+  }
+
+  /**
+   * Get the config key controlling the thread keep-alive.
+   * This is for testing only.
+   * @return the keep-alive config key
+   */
+  @VisibleForTesting
+  public String getKeepAliveKey() {
+    return keepAliveKey;
+  }
+
+  /**
+   * Get the shared executor, creating it on first call if enabled.
+   * @param conf configuration
+   * @return the executor, or null if not enabled
+   */
+  public synchronized ScheduledExecutorService get(Configuration conf) {
+    if (executor == null) {
+      if (conf.getBoolean(enabledKey, false)) {
+        int poolSize = intOption(conf, sizeKey,
+            AWS_CLIENT_SHARED_THREADPOOL_SIZE_DEFAULT, 1);
+        int keepAlive = intOption(conf, keepAliveKey,
+            AWS_CLIENT_SHARED_THREADPOOL_KEEPALIVE_DEFAULT, 1);
+        executor = Optional.of(createScheduledExecutor(namePrefix, poolSize, 
keepAlive));
+      } else {
+        executor = Optional.empty();
+      }
+    }
+    return executor.orElse(null);
+  }
+
+  /**
+   * Create a scheduled executor with idle thread timeout.
+   * @param namePrefix thread name prefix for debugging
+   * @param poolSize core pool size
+   * @param keepAliveSeconds keepalive time in seconds
+   * @return the executor
+   */
+  @VisibleForTesting
+  public static ScheduledExecutorService createScheduledExecutor(
+      String namePrefix, int poolSize, int keepAliveSeconds) {
+    ScheduledThreadPoolExecutor pool = new 
ScheduledThreadPoolExecutor(poolSize,
+        runnable -> {
+          Thread t = new Thread(runnable, namePrefix);
+          t.setDaemon(true);
+          return t;
+        });
+    pool.setKeepAliveTime(keepAliveSeconds, TimeUnit.SECONDS);
+    pool.allowCoreThreadTimeOut(true);
+    LOG.debug("Created shared executor '{}' with pool size {} and keepalive 
{}s",
+        namePrefix, poolSize, keepAliveSeconds);
+    return pool;
+  }
+}
diff --git 
a/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/ITestS3ASharedThreadPoolDisabled.java
 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/ITestS3ASharedThreadPoolDisabled.java
new file mode 100644
index 00000000000..d78c7135b59
--- /dev/null
+++ 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/ITestS3ASharedThreadPoolDisabled.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.hadoop.fs.s3a;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.conf.Configuration;
+
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED;
+
+/**
+ * Control case for {@link ITestS3ASharedThreadPoolEnabled}: with the shared
+ * pool disabled (the default), each S3AFileSystem's AWS SDK clients create 
their
+ * own sdk-ScheduledExecutor pool, so thread count grows with the number of
+ * instances. Must run in its own JVM: the static pool holder is single-state
+ * per JVM, so this control (pools off) and {@link 
ITestS3ASharedThreadPoolEnabled}
+ * (pools on) cannot share a fork. hadoop-aws sets reuseForks=false, which 
these
+ * tests require; do not enable fork reuse for them.
+ * <p>
+ * The sdk-ScheduledExecutor pools are created lazily as the clients schedule
+ * work, so the exact count varies; the assertion is only that it clearly
+ * exceeds a single shared pool.
+ */
+public class ITestS3ASharedThreadPoolDisabled extends AbstractS3ATestBase {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ITestS3ASharedThreadPoolDisabled.class);
+
+  private static final int INSTANCES = 20;
+
+  /** Pool size the shared pools would otherwise cap at, per type. */
+  private static final int SHARED_POOL_SIZE = 5;
+
+  /** Thread name prefix the AWS SDK uses for its per-client default pool. */
+  private static final String SDK_POOL_PREFIX = "sdk-ScheduledExecutor";
+
+  @Override
+  protected Configuration createConfiguration() {
+    Configuration conf = super.createConfiguration();
+    // Strip any base/per-bucket override, then pin the pools explicitly off, 
so
+    // this control is deterministic regardless of the default: it must run 
with
+    // the shared pools disabled to exercise the per-client leak.
+    S3ATestUtils.removeBaseAndBucketOverrides(
+        S3ATestUtils.getTestBucketName(conf), conf,
+        AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED);
+    conf.setBoolean(AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED, false);
+    conf.setBoolean(AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED, false);
+    return conf;
+  }
+
+  @Test
+  public void testWithoutSharedPoolThreadsLeakPastGc() throws Exception {
+    // Abandon many uncached instances and force GC: without sharing, each
+    // client's own scheduled pool is leaked (not shut down on eviction, and
+    // rooted by its own running threads), so the threads survive collection.
+    int sdkThreads = 
S3ATestUtils.countSchedulerThreadsAfterAbandoningFilesystems(
+        getFileSystem().getUri(), getConfiguration(), INSTANCES,
+        SDK_POOL_PREFIX);
+    LOG.info("SDK scheduler threads surviving GC after abandoning {} "
+        + "S3AFileSystem instances without the shared pool: {}", INSTANCES,
+        sdkThreads);
+
+    // The leaked per-client pools persist past GC, far exceeding a single
+    // shared pool.
+    Assertions.assertThat(sdkThreads)
+        .as("Without the shared pool, abandoned sdk-ScheduledExecutor threads "
+            + "should leak past GC, exceeding a single shared pool after %s "
+            + "instances", INSTANCES)
+        .isGreaterThan(2 * SHARED_POOL_SIZE);
+
+    // Intentionally not cleaned up: the abandoned uncached instances leave no
+    // handle to close, and that unreclaimability is the point. The daemon
+    // threads are reaped when this class's own fork exits (reuseForks=false).
+  }
+}
diff --git 
a/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/ITestS3ASharedThreadPoolEnabled.java
 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/ITestS3ASharedThreadPoolEnabled.java
new file mode 100644
index 00000000000..e7406f7d910
--- /dev/null
+++ 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/ITestS3ASharedThreadPoolEnabled.java
@@ -0,0 +1,106 @@
+/*
+ * 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.hadoop.fs.s3a;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hadoop.conf.Configuration;
+
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE;
+
+/**
+ * Integration test demonstrating that the shared scheduled thread pool bounds
+ * thread growth across many S3AFileSystem instances.
+ * <p>
+ * Background (HADOOP-19862): each S3AFileSystem builds its own AWS SDK 
clients,
+ * and each client otherwise creates a 5-thread sdk-ScheduledExecutor pool, so
+ * creating many instances grows threads without bound. With the shared pool
+ * enabled, all clients of a type reuse a single pool, so the thread count 
stays
+ * bounded regardless of how many instances are created.
+ * <p>
+ * This test proves the fix direction (pool enabled, threads bounded);
+ * {@link ITestS3ASharedThreadPoolDisabled} is the control proving the opposite
+ * (pool disabled, threads grow). The two must be separate classes: the holders
+ * are private static final and memoize on first use, so the first 
configuration
+ * to reach them wins for the JVM. They therefore cannot share a fork; each 
must
+ * run in its own JVM. hadoop-aws sets reuseForks=false, which these tests
+ * require (do not enable fork reuse for them); do not merge them into one 
class.
+ * <p>
+ * Exact thread counts depend on AWS SDK internals, so the assertion is on the
+ * bound (not exceeding the pool sizes), not an exact number.
+ */
+public class ITestS3ASharedThreadPoolEnabled extends AbstractS3ATestBase {
+
+  private static final Logger LOG =
+      LoggerFactory.getLogger(ITestS3ASharedThreadPoolEnabled.class);
+
+  private static final int POOL_SIZE = 5;
+
+  /** Number of distinct filesystem instances to create. */
+  private static final int INSTANCES = 20;
+
+  /** Thread name prefixes used by the shared pools (see the factories). */
+  private static final String[] SHARED_POOL_PREFIXES = {
+      "s3a-s3-sync-scheduler",
+      "s3a-s3-async-scheduler",
+  };
+
+  @Override
+  protected Configuration createConfiguration() {
+    Configuration conf = super.createConfiguration();
+    // Strip any base or per-bucket overrides for these keys so the values set
+    // below win: a bucket override would otherwise take precedence and
+    // silently invalidate the test.
+    S3ATestUtils.removeBaseAndBucketOverrides(
+        S3ATestUtils.getTestBucketName(conf), conf,
+        AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE);
+    // Enable the shared pools before any client is built, so the static
+    // holders memoize in the enabled state for this JVM.
+    conf.setBoolean(AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED, true);
+    conf.setBoolean(AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED, true);
+    conf.setInt(AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE, POOL_SIZE);
+    conf.setInt(AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE, POOL_SIZE);
+    return conf;
+  }
+
+  @Test
+  public void testSharedPoolBoundsThreadsAcrossManyInstances() throws 
Exception {
+    int shared = S3ATestUtils.countSchedulerThreadsAfterAbandoningFilesystems(
+        getFileSystem().getUri(), getConfiguration(), INSTANCES,
+        SHARED_POOL_PREFIXES);
+    LOG.info("Shared scheduler threads after abandoning {} S3AFileSystem "
+        + "instances and forcing GC: {}", INSTANCES, shared);
+
+    // With sharing, the single static pool survives instance abandonment + GC
+    // and stays bounded by the two pools' core sizes, regardless of count.
+    Assertions.assertThat(shared)
+        .as("Total shared scheduler threads after abandoning %s S3AFileSystem "
+            + "instances should be bounded by the pool sizes", INSTANCES)
+        .isLessThanOrEqualTo(2 * POOL_SIZE);
+  }
+}
diff --git 
a/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/S3ATestUtils.java
 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/S3ATestUtils.java
index e5205d64854..cf5c6b45b4b 100644
--- 
a/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/S3ATestUtils.java
+++ 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/S3ATestUtils.java
@@ -87,6 +87,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.UncheckedIOException;
+import java.lang.ref.WeakReference;
 import java.lang.reflect.Field;
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -101,6 +102,8 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import static org.apache.hadoop.fs.contract.ContractTestUtils.createFile;
@@ -115,6 +118,8 @@
 import static 
org.apache.hadoop.fs.s3a.test.PublicDatasetTestUtils.getExternalData;
 import static 
org.apache.hadoop.fs.s3a.test.PublicDatasetTestUtils.requireDefaultExternalDataFile;
 import static org.apache.hadoop.test.GenericTestUtils.buildPaths;
+import static org.apache.hadoop.test.GenericTestUtils.countThreadsMatching;
+import static org.apache.hadoop.test.GenericTestUtils.waitFor;
 import static org.apache.hadoop.util.Preconditions.checkNotNull;
 import static 
org.apache.hadoop.fs.CommonConfigurationKeysPublic.HADOOP_SECURITY_CREDENTIAL_PROVIDER_PATH;
 import static org.apache.commons.lang3.StringUtils.isNotEmpty;
@@ -2052,4 +2057,49 @@ public static Pair<Long, Long> requestRange(String 
rangeHeader) {
     // error case
     return Pair.of(-1L, -1L);
   }
+
+  /**
+   * Count live threads (by name prefix) that survive collection of the
+   * {@code S3AFileSystem} instances owning them. Creates {@code instances}
+   * uncached filesystems, lists on each to spawn its SDK scheduler threads,
+   * then abandons them and requests GC until a {@link WeakReference} to one
+   * clears — proving an owner was really collected, as {@link System#gc()} is
+   * only a hint. Per aws-sdk-java-v2 #1690 a leaked per-client scheduler's
+   * running threads root its executor, so it outlives its owner.
+   * @param uri filesystem URI
+   * @param conf base configuration (filesystem caching is forced off here)
+   * @param instances number of filesystem instances to create and abandon
+   * @param prefixes scheduler thread name prefixes to count
+   * @return number of matching live threads remaining after the owners are 
collected
+   * @throws IOException creating a filesystem or listing failed
+   * @throws InterruptedException interrupted while waiting for collection
+   * @throws TimeoutException an abandoned instance was not collected in time
+   */
+  public static int countSchedulerThreadsAfterAbandoningFilesystems(URI uri,
+      Configuration conf, int instances, String... prefixes)
+      throws IOException, InterruptedException, TimeoutException {
+    Configuration uncached = new Configuration(conf);
+    disableFilesystemCaching(uncached);
+    WeakReference<FileSystem> sentinel = null;
+    for (int i = 0; i < instances; i++) {
+      // Uncached instance; deliberately neither retained nor closed, so it
+      // becomes eligible for collection once this iteration ends.
+      FileSystem fs = FileSystem.get(uri, uncached);
+      fs.listStatus(new Path("/"));
+      if (sentinel == null) {
+        sentinel = new WeakReference<>(fs);
+      }
+    }
+    // System.gc() is only a hint, so prove a real collection happened: request
+    // GC until the weak reference to an abandoned instance clears.
+    final WeakReference<FileSystem> ref = sentinel;
+    waitFor(() -> {
+      System.gc();
+      return ref.get() == null;
+    }, 100, 30_000);
+    Pattern pattern = Pattern.compile(Arrays.stream(prefixes)
+        .map(p -> Pattern.quote(p) + ".*")
+        .collect(Collectors.joining("|")));
+    return countThreadsMatching(pattern);
+  }
 }
diff --git 
a/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/TestSharedScheduledExecutor.java
 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/TestSharedScheduledExecutor.java
new file mode 100644
index 00000000000..f302fc177b7
--- /dev/null
+++ 
b/hadoop-tools/hadoop-aws/src/test/java/org/apache/hadoop/fs/s3a/TestSharedScheduledExecutor.java
@@ -0,0 +1,450 @@
+/*
+ * 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.hadoop.fs.s3a;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.s3a.auth.STSClientFactory;
+import org.apache.hadoop.fs.s3a.impl.EncryptionS3ClientFactory;
+import org.apache.hadoop.fs.s3a.impl.LazySharedThreadPoolHolder;
+import org.apache.hadoop.test.GenericTestUtils;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_CLIENT_SHARED_THREADPOOL_KEEPALIVE_DEFAULT;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_CLIENT_SHARED_THREADPOOL_SIZE_DEFAULT;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_KMS_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_KMS_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_KMS_CLIENT_SHARED_THREADPOOL_SIZE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_STS_CLIENT_SHARED_THREADPOOL_ENABLED;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_STS_CLIENT_SHARED_THREADPOOL_KEEPALIVE;
+import static 
org.apache.hadoop.fs.s3a.Constants.AWS_STS_CLIENT_SHARED_THREADPOOL_SIZE;
+
+/**
+ * Tests for the shared scheduled executors in DefaultS3ClientFactory.
+ */
+public class TestSharedScheduledExecutor {
+
+  @Test
+  public void testLazyHolderDisabledByDefault() {
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        "test.enabled", "test.size", "test.keepalive", "test-disabled");
+    Configuration conf = new Configuration();
+    ScheduledExecutorService executor = holder.get(conf);
+    Assertions.assertThat(executor)
+        .as("Executor should be null when disabled")
+        .isNull();
+  }
+
+  @Test
+  public void testCreateScheduledExecutorConfiguration() {
+    ScheduledExecutorService executor =
+        LazySharedThreadPoolHolder.createScheduledExecutor("test-scheduler", 
10, 30);
+    Assertions.assertThat(executor)
+        .as("Executor should be a ScheduledThreadPoolExecutor")
+        .isInstanceOf(ScheduledThreadPoolExecutor.class);
+
+    ScheduledThreadPoolExecutor poolExecutor = (ScheduledThreadPoolExecutor) 
executor;
+    Assertions.assertThat(poolExecutor.getCorePoolSize())
+        .as("Core pool size should be 10")
+        .isEqualTo(10);
+    Assertions.assertThat(poolExecutor.getKeepAliveTime(TimeUnit.SECONDS))
+        .as("Keepalive should be 30 seconds")
+        .isEqualTo(30);
+    Assertions.assertThat(poolExecutor.allowsCoreThreadTimeOut())
+        .as("Core threads should be allowed to time out")
+        .isTrue();
+
+    executor.shutdown();
+  }
+
+  @Test
+  public void testCreateScheduledExecutorThreadsAreDaemon() throws Exception {
+    ScheduledExecutorService executor =
+        LazySharedThreadPoolHolder.createScheduledExecutor("test-daemon", 5, 
60);
+    final boolean[] isDaemon = new boolean[1];
+    executor.submit(() -> {
+      isDaemon[0] = Thread.currentThread().isDaemon();
+    }).get();
+    Assertions.assertThat(isDaemon[0])
+        .as("Executor threads should be daemon threads")
+        .isTrue();
+    executor.shutdown();
+  }
+
+  @Test
+  public void testCreateScheduledExecutorThreadName() throws Exception {
+    ScheduledExecutorService executor =
+        LazySharedThreadPoolHolder.createScheduledExecutor("custom-prefix", 5, 
60);
+    final String[] threadName = new String[1];
+    executor.submit(() -> {
+      threadName[0] = Thread.currentThread().getName();
+    }).get();
+    Assertions.assertThat(threadName[0])
+        .as("Thread name should match custom prefix")
+        .startsWith("custom-prefix");
+    executor.shutdown();
+  }
+
+  @Test
+  public void testLazyHolderRejectsNegativePoolSize() {
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        "test.enabled", "test.size", "test.keepalive", "test-pool");
+    Configuration conf = new Configuration();
+    conf.setBoolean("test.enabled", true);
+    conf.setInt("test.size", -1);
+    conf.setInt("test.keepalive", 60);
+    Assertions.assertThatThrownBy(() -> holder.get(conf))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("test.size")
+        .hasMessageContaining("below the minimum value");
+  }
+
+  @Test
+  public void testLazyHolderRejectsZeroPoolSize() {
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        "test.enabled", "test.size", "test.keepalive", "test-pool");
+    Configuration conf = new Configuration();
+    conf.setBoolean("test.enabled", true);
+    conf.setInt("test.size", 0);
+    conf.setInt("test.keepalive", 60);
+    Assertions.assertThatThrownBy(() -> holder.get(conf))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("test.size")
+        .hasMessageContaining("below the minimum value");
+  }
+
+  @Test
+  public void testLazyHolderRejectsNegativeKeepAlive() {
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        "test.enabled", "test.size", "test.keepalive", "test-pool");
+    Configuration conf = new Configuration();
+    conf.setBoolean("test.enabled", true);
+    conf.setInt("test.size", 5);
+    conf.setInt("test.keepalive", -1);
+    Assertions.assertThatThrownBy(() -> holder.get(conf))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("test.keepalive")
+        .hasMessageContaining("below the minimum value");
+  }
+
+  @Test
+  public void testLazyHolderRejectsZeroKeepAlive() {
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        "test.enabled", "test.size", "test.keepalive", "test-pool");
+    Configuration conf = new Configuration();
+    conf.setBoolean("test.enabled", true);
+    conf.setInt("test.size", 5);
+    conf.setInt("test.keepalive", 0);
+    Assertions.assertThatThrownBy(() -> holder.get(conf))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("test.keepalive")
+        .hasMessageContaining("below the minimum value");
+  }
+
+  @Test
+  public void testLazyHolderAcceptsValidConfig() {
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        "test.enabled", "test.size", "test.keepalive", "test-valid");
+    Configuration conf = new Configuration();
+    conf.setBoolean("test.enabled", true);
+    conf.setInt("test.size", 5);
+    conf.setInt("test.keepalive", 30);
+    ScheduledExecutorService executor = holder.get(conf);
+    Assertions.assertThat(executor)
+        .as("Executor should be created with valid config")
+        .isNotNull()
+        .isInstanceOf(ScheduledThreadPoolExecutor.class);
+
+    ScheduledThreadPoolExecutor poolExecutor =
+        (ScheduledThreadPoolExecutor) executor;
+    Assertions.assertThat(poolExecutor.getCorePoolSize())
+        .as("Core pool size should match configured value")
+        .isEqualTo(5);
+    Assertions.assertThat(poolExecutor.getKeepAliveTime(TimeUnit.SECONDS))
+        .as("Keepalive should match configured value")
+        .isEqualTo(30);
+    executor.shutdown();
+  }
+
+  /**
+   * Count threads matching the given prefix.
+   * @param prefix thread name prefix to match
+   * @return count of matching threads
+   */
+  private int countThreadsWithPrefix(String prefix) {
+    return GenericTestUtils.countThreadsMatching(
+        Pattern.compile(Pattern.quote(prefix) + ".*"));
+  }
+
+  /**
+   * Test that without shared pool, each holder creates its own threads.
+   * This demonstrates the thread growth problem.
+   */
+  @Test
+  public void testWithoutSharedPoolThreadsGrow() throws Exception {
+    final String prefix = "test-growth-";
+    final int poolSize = 3;
+    final int numHolders = 5;
+    List<ScheduledExecutorService> executors = new ArrayList<>();
+
+    int initialCount = countThreadsWithPrefix(prefix);
+
+    for (int i = 0; i < numHolders; i++) {
+      ScheduledExecutorService executor =
+          LazySharedThreadPoolHolder.createScheduledExecutor(prefix + i, 
poolSize, 60);
+      executors.add(executor);
+      executor.submit(() -> {}).get();
+    }
+
+    int afterCount = countThreadsWithPrefix(prefix);
+    int newThreads = afterCount - initialCount;
+
+    Assertions.assertThat(newThreads)
+        .as("Without shared pool, thread count should grow with each executor")
+        .isGreaterThanOrEqualTo(numHolders);
+
+    for (ScheduledExecutorService executor : executors) {
+      executor.shutdown();
+    }
+  }
+
+  /**
+   * Test that with shared pool enabled, thread count is bounded.
+   * This demonstrates the fix for thread leak.
+   */
+  @Test
+  public void testWithSharedPoolThreadCountBounded() throws Exception {
+    final String prefix = "test-shared-";
+    final int poolSize = 5;
+    final int numCalls = 10;
+
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+        prefix);
+
+    Configuration conf = new Configuration();
+    conf.setBoolean(AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED, true);
+    conf.setInt(AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE, poolSize);
+    conf.setInt(AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE, 60);
+
+    int initialCount = countThreadsWithPrefix(prefix);
+
+    for (int i = 0; i < numCalls; i++) {
+      ScheduledExecutorService executor = holder.get(conf);
+      Assertions.assertThat(executor)
+          .as("Should return same executor instance")
+          .isNotNull();
+      executor.submit(() -> {}).get();
+    }
+
+    int afterCount = countThreadsWithPrefix(prefix);
+    int newThreads = afterCount - initialCount;
+
+    Assertions.assertThat(newThreads)
+        .as("With shared pool, thread count should be bounded by pool size")
+        .isLessThanOrEqualTo(poolSize);
+
+    holder.get(conf).shutdown();
+  }
+
+  /**
+   * Test that holder returns the same executor instance on repeated calls.
+   */
+  @Test
+  public void testHolderReturnsSameInstance() {
+    LazySharedThreadPoolHolder holder = new LazySharedThreadPoolHolder(
+        "test.enabled", "test.size", "test.keepalive", "test-same");
+    Configuration conf = new Configuration();
+    conf.setBoolean("test.enabled", true);
+    conf.setInt("test.size", 5);
+    conf.setInt("test.keepalive", 60);
+
+    ScheduledExecutorService first = holder.get(conf);
+    ScheduledExecutorService second = holder.get(conf);
+    ScheduledExecutorService third = holder.get(conf);
+
+    Assertions.assertThat(first)
+        .as("Holder should return same instance on repeated calls")
+        .isSameAs(second)
+        .isSameAs(third);
+
+    first.shutdown();
+  }
+
+  /**
+   * Verify the production shared-thread-pool config keys for every AWS client
+   * type are well-formed: within each client the enabled/size/keepalive keys
+   * share a single base and differ only by the expected suffix, and all keys
+   * are globally unique. This guards against the copy/paste or 
swapped-argument
+   * mistakes that are easy to make across the near-identical client
+   * definitions, where the holder constructor takes only strings.
+   */
+  @Test
+  public void testProductionThreadPoolConfigKeysWellFormed() {
+    String[][] clients = {
+        {AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED,
+            AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE,
+            AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE},
+        {AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED,
+            AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE,
+            AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_KEEPALIVE},
+        {AWS_STS_CLIENT_SHARED_THREADPOOL_ENABLED,
+            AWS_STS_CLIENT_SHARED_THREADPOOL_SIZE,
+            AWS_STS_CLIENT_SHARED_THREADPOOL_KEEPALIVE},
+        {AWS_KMS_CLIENT_SHARED_THREADPOOL_ENABLED,
+            AWS_KMS_CLIENT_SHARED_THREADPOOL_SIZE,
+            AWS_KMS_CLIENT_SHARED_THREADPOOL_KEEPALIVE},
+    };
+
+    List<String> allKeys = new ArrayList<>();
+    for (String[] client : clients) {
+      String enabled = client[0];
+      String size = client[1];
+      String keepAlive = client[2];
+
+      Assertions.assertThat(enabled)
+          .as("Enabled key should end with .threadpool.enabled")
+          .endsWith(".threadpool.enabled");
+      String base =
+          enabled.substring(0, enabled.length() - ".enabled".length());
+      Assertions.assertThat(size)
+          .as("Size key should be base + .size for base %s", base)
+          .isEqualTo(base + ".size");
+      Assertions.assertThat(keepAlive)
+          .as("Keepalive key should be base + .keepalive.seconds for base %s",
+              base)
+          .isEqualTo(base + ".keepalive.seconds");
+
+      allKeys.add(enabled);
+      allKeys.add(size);
+      allKeys.add(keepAlive);
+    }
+
+    Assertions.assertThat(allKeys)
+        .as("All shared thread pool config keys should be unique")
+        .doesNotHaveDuplicates();
+  }
+
+  /**
+   * Verify the shared-thread-pool defaults declared in core-default.xml match
+   * the constants used by the lazy holders: every pool is disabled by default
+   * and the size/keepalive defaults are the documented values. This catches a
+   * typo in a core-default.xml name or a mismatch between Constants, the 
holder
+   * defaults and core-default.xml.
+   */
+  @Test
+  public void testProductionThreadPoolDefaultsInCoreDefaultXml() {
+    Configuration conf = new Configuration();
+    String[] enabledKeys = {
+        AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_STS_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_KMS_CLIENT_SHARED_THREADPOOL_ENABLED,
+    };
+    String[] sizeKeys = {
+        AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_STS_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_KMS_CLIENT_SHARED_THREADPOOL_SIZE,
+    };
+    String[] keepAliveKeys = {
+        AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+        AWS_STS_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+        AWS_KMS_CLIENT_SHARED_THREADPOOL_KEEPALIVE,
+    };
+
+    for (String key : enabledKeys) {
+      Assertions.assertThat(conf.getBoolean(key, true))
+          .as("%s should default to false in core-default.xml", key)
+          .isFalse();
+    }
+    for (String key : sizeKeys) {
+      Assertions.assertThat(conf.getInt(key, -1))
+          .as("%s should match the documented default in core-default.xml", 
key)
+          .isEqualTo(AWS_CLIENT_SHARED_THREADPOOL_SIZE_DEFAULT);
+    }
+    for (String key : keepAliveKeys) {
+      Assertions.assertThat(conf.getInt(key, -1))
+          .as("%s should match the documented default in core-default.xml", 
key)
+          .isEqualTo(AWS_CLIENT_SHARED_THREADPOOL_KEEPALIVE_DEFAULT);
+    }
+  }
+
+  /**
+   * Verify each AWS client factory wires its shared-thread-pool holder with 
the
+   * config keys for that client, in the correct slots. The holder constructor
+   * takes only strings, so a swapped or mis-pasted key would otherwise be
+   * silent; this asserts the enabled/size/keepalive keys land where intended.
+   */
+  @Test
+  public void testFactoryExecutorHoldersWiredWithExpectedKeys() {
+    assertHolderKeys(DefaultS3ClientFactory.s3SyncExecutorHolder(),
+        AWS_S3_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_S3_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_S3_CLIENT_SHARED_THREADPOOL_KEEPALIVE);
+    assertHolderKeys(DefaultS3ClientFactory.s3AsyncExecutorHolder(),
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_S3_ASYNC_CLIENT_SHARED_THREADPOOL_KEEPALIVE);
+    assertHolderKeys(STSClientFactory.stsExecutorHolder(),
+        AWS_STS_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_STS_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_STS_CLIENT_SHARED_THREADPOOL_KEEPALIVE);
+    assertHolderKeys(EncryptionS3ClientFactory.kmsExecutorHolder(),
+        AWS_KMS_CLIENT_SHARED_THREADPOOL_ENABLED,
+        AWS_KMS_CLIENT_SHARED_THREADPOOL_SIZE,
+        AWS_KMS_CLIENT_SHARED_THREADPOOL_KEEPALIVE);
+  }
+
+  /**
+   * Assert a holder is wired with the expected enabled/size/keepalive keys.
+   * @param holder the holder under test
+   * @param enabledKey expected enabled key
+   * @param sizeKey expected size key
+   * @param keepAliveKey expected keepalive key
+   */
+  private static void assertHolderKeys(LazySharedThreadPoolHolder holder,
+      String enabledKey, String sizeKey, String keepAliveKey) {
+    Assertions.assertThat(holder.getEnabledKey())
+        .as("enabled key")
+        .isEqualTo(enabledKey);
+    Assertions.assertThat(holder.getSizeKey())
+        .as("size key")
+        .isEqualTo(sizeKey);
+    Assertions.assertThat(holder.getKeepAliveKey())
+        .as("keepalive key")
+        .isEqualTo(keepAliveKey);
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to