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

saihemanth-cloudera pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hive.git


The following commit(s) were added to refs/heads/master by this push:
     new f845df56e86 HIVE-29818: ServletSecurity needs a UGI cache (#6704)
f845df56e86 is described below

commit f845df56e862972344fd6bc422faefa263e67430
Author: Henrib <[email protected]>
AuthorDate: Wed Sep 16 23:51:21 2026 +0200

    HIVE-29818: ServletSecurity needs a UGI cache (#6704)
    
    * HIVE-29818: ServletSecurity needs a UGI cache
    
    The REST Catalog creates a fresh proxy UserGroupInformation per request via
    UserGroupInformation.createProxyUser. Hadoop's FileSystem.CACHE retains a
    reference to every such UGI (and its RPC/IPC resources), so under proxy
    authentication these short-lived UGIs accumulate and eventually exhaust 
memory
    in long-running deployments.
    
    Cache the proxy UGI in ServletSecurity with a bounded, idle-evicting 
Caffeine
    cache keyed by (realUser, loginUser). Evicted entries release their 
resources
    via FileSystem.closeAllForUGI. Two new config vars tune the cache:
      - metastore.catalog.servlet.ugi.cache.size   (default 1000)
      - metastore.catalog.servlet.ugi.cache.expiry (default 3600s, 0 disables)
    
    Add TestServletSecurity covering per-user caching, distinct proxies per 
user,
    eviction-triggered FileSystem cleanup, and disabled expiry.
    
    * HIVE-29818: Address review comments on ServletSecurity UGI cache
    
    - Rename UgiKey.realUser to effectiveUser (impersonated user, not the 
Hadoop real user)
    - Keep cache size as long end-to-end, dropping the truncating int cast
    - Run removal-listener cleanup on ForkJoinPool.commonPool() instead of the 
request thread;
      add a test-only constructor to inject a synchronous executor
    - Downgrade proxy-user creation log from INFO to DEBUG
    
    * CDPD-29818 : Address review comments on ServletSecurity UGI cache 
(dedicated executor + periodic cleanup)
    
    Replace ForkJoinPool.commonPool() with a dedicated single-thread daemon
    executor ("ugi-cache-cleanup") for Caffeine removal-listener callbacks,
    preventing blocking FileSystem.closeAllForUGI calls from interfering with
    JVM-wide shared pools.
    
    Schedule proxyUserCache.cleanUp() on the same executor at the configured
    expiry interval so that entries accumulated during traffic bursts are
    reaped after they expire, even when no subsequent requests arrive to
    trigger Caffeine's access-piggybacked maintenance.
    
    Add close() / ProxyServlet.destroy() for executor lifecycle management.
    The @VisibleForTesting constructor is unaffected (no scheduler, no
    lifecycle).
---
 .../hadoop/hive/metastore/conf/MetastoreConf.java  |  11 ++
 .../hadoop/hive/metastore/ServletSecurity.java     | 149 ++++++++++++++++++++-
 .../hadoop/hive/metastore/TestServletSecurity.java | 102 ++++++++++++++
 3 files changed, 260 insertions(+), 2 deletions(-)

diff --git 
a/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
 
b/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
index accc6c4b4df..96f1db1db2b 100644
--- 
a/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
+++ 
b/standalone-metastore/metastore-common/src/main/java/org/apache/hadoop/hive/metastore/conf/MetastoreConf.java
@@ -1980,6 +1980,17 @@ public enum ConfVars {
         "hive.metastore.iceberg.catalog.metrics.reporters", 
"org.apache.iceberg.rest.metrics.LoggingMetricsReporter",
         "A comma separated list of custom Iceberg Metrics Reporting plugins."
     ),
+    CATALOG_SERVLET_UGI_CACHE_SIZE("metastore.catalog.servlet.ugi.cache.size",
+        "hive.metastore.catalog.servlet.ugi.cache.size", 1000L,
+        "Maximum number of proxy UserGroupInformation instances to keep in the 
catalog servlet UGI cache. " +
+        "Entries displaced by this limit trigger FileSystem resource cleanup 
for the evicted UGI."
+    ),
+    
CATALOG_SERVLET_UGI_CACHE_EXPIRY("metastore.catalog.servlet.ugi.cache.expiry",
+        "hive.metastore.catalog.servlet.ugi.cache.expiry", 3600, 
TimeUnit.SECONDS,
+        "Idle-expiry time for cached proxy UserGroupInformation instances in 
the catalog servlet. " +
+        "After this period of inactivity, the entry is evicted and 
FileSystem.closeAllForUGI is called " +
+        "to release associated IPC and RPC resources. Set to 0 to disable 
expiry-based eviction."
+    ),
     HTTPSERVER_THREADPOOL_MIN("hive.metastore.httpserver.threadpool.min",
             "hive.metastore.httpserver.threadpool.min", 8,
             "HMS embedded HTTP server minimum number of threads."
diff --git 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java
 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java
index 944c490e978..f7d6a5e77b5 100644
--- 
a/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java
+++ 
b/standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java
@@ -21,12 +21,23 @@
 
 import static javax.ws.rs.core.HttpHeaders.WWW_AUTHENTICATE;
 
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalListener;
+import com.github.benmanes.caffeine.cache.Ticker;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 
 import java.security.KeyStore;
+import java.time.Duration;
 import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.hive.metastore.auth.HttpAuthenticationException;
 import org.apache.hadoop.hive.metastore.auth.jwt.SimpleJWTAuthenticator;
 import org.apache.hadoop.hive.metastore.auth.oauth2.OAuth2Authenticator;
@@ -106,6 +117,14 @@ public static AuthType fromString(String type) {
   private final Function<HttpServletRequest, List<String>> scopeProvider;
   private SimpleJWTAuthenticator jwtAuthenticator = null;
   private OAuth2Authenticator oAuth2Authenticator = null;
+  private final Cache<UgiKey, UserGroupInformation> proxyUserCache;
+  private final ScheduledExecutorService cacheMaintenanceExecutor;
+
+  /**
+   * Cache key for a proxy {@link UserGroupInformation}. A proxy UGI is bound 
to both the effective user it
+   * impersonates and the server login user acting as its real user, so both 
participate in identity.
+   */
+  record UgiKey(String effectiveUser, String loginUser) {}
 
   public ServletSecurity(AuthType authType, Configuration conf) {
     this(authType, conf, null);
@@ -117,6 +136,127 @@ public ServletSecurity(AuthType authType, Configuration 
conf,
     this.isSecurityEnabled = UserGroupInformation.isSecurityEnabled();
     this.authType = authType;
     this.scopeProvider = scopeProvider;
+    this.cacheMaintenanceExecutor = 
Executors.newSingleThreadScheduledExecutor(r -> {
+      Thread t = new Thread(r, "ugi-cache-cleanup");
+      t.setDaemon(true);
+      return t;
+    });
+    long expiryMs = MetastoreConf.getTimeVar(conf, 
MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_EXPIRY,
+        TimeUnit.MILLISECONDS);
+    this.proxyUserCache = createCacheWithConfig(
+        expiryMs,
+        MetastoreConf.getLongVar(conf, 
MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_SIZE),
+        cacheMaintenanceExecutor);
+    if (expiryMs > 0) {
+      // Periodically flush expired entries that accumulated during idle 
periods (Caffeine only runs
+      // maintenance when the cache is accessed; without this, a burst 
followed by silence keeps
+      // UGI/FileSystem resources live past the configured expiry window).
+      cacheMaintenanceExecutor.scheduleAtFixedRate(proxyUserCache::cleanUp,
+          expiryMs, expiryMs, TimeUnit.MILLISECONDS);
+    }
+  }
+
+  @VisibleForTesting
+  ServletSecurity(AuthType authType, Configuration conf,
+      Function<HttpServletRequest, List<String>> scopeProvider, Executor 
cacheCleanupExecutor) {
+    this.conf = conf;
+    this.isSecurityEnabled = UserGroupInformation.isSecurityEnabled();
+    this.authType = authType;
+    this.scopeProvider = scopeProvider;
+    this.cacheMaintenanceExecutor = null;
+    this.proxyUserCache = createCacheWithConfig(
+        MetastoreConf.getTimeVar(conf, 
MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_EXPIRY, TimeUnit.MILLISECONDS),
+        MetastoreConf.getLongVar(conf, 
MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_SIZE),
+        cacheCleanupExecutor);
+  }
+
+  /**
+   * Creates a UGI cache with the specified expiration time and maximum size.
+   *
+   * @param expirationMs Time in milliseconds after which entries expire due 
to inactivity
+   * @param maxSize      Maximum number of entries the cache can hold
+   * @param cacheCleanupExecutor executor on which removal-listener cleanup 
({@link FileSystem#closeAllForUGI})
+   *                             runs; production uses a dedicated 
single-thread daemon executor so cleanup stays
+   *                             off request threads and does not interfere 
with JVM-wide shared pools
+   * @return A configured Caffeine cache for UGI objects
+   */
+  private Cache<UgiKey, UserGroupInformation> createCacheWithConfig(long 
expirationMs, long maxSize,
+      Executor cacheCleanupExecutor) {
+    // Note: eviction closes the UGI's FileSystems. If an entry is evicted 
while a request is still inside doAs,
+    // that in-flight operation could see a "FileSystem closed" error. We 
don't reference-count to prevent this;
+    // instead we rely on generous margins: expiry is idle-based 
(expireAfterAccess), and both the expiry window
+    // and maximumSize should be kept well above the longest operation / peak 
concurrent distinct users.
+    RemovalListener<UgiKey, UserGroupInformation> cleanupListener =
+        (key, ugi, cause) -> {
+          if (ugi != null) {
+            try {
+              FileSystem.closeAllForUGI(ugi);
+              if (LOG.isDebugEnabled()) {
+                LOG.debug("Cleaned up FileSystem handles for evicted UGI: {} 
(cause: {})",
+                    ugi.getUserName(), cause);
+              }
+            } catch (IOException cleanupException) {
+              LOG.error("Failed to clean up FileSystem handles for evicted 
UGI: {} (cause: {})",
+                  ugi, cause, cleanupException);
+            }
+          }
+        };
+
+    Caffeine<UgiKey, UserGroupInformation> builder = Caffeine.<UgiKey, 
UserGroupInformation>newBuilder()
+        .maximumSize(maxSize)
+        .executor(cacheCleanupExecutor)
+        .removalListener(cleanupListener);
+
+    if (expirationMs > 0) {
+      builder.expireAfterAccess(Duration.ofMillis(expirationMs))
+          .ticker(Ticker.systemTicker());
+    }
+
+    return builder.build();
+  }
+
+  /**
+   * Returns the (cached) proxy {@link UserGroupInformation} for the given 
user, creating it on a cache miss.
+   * <p>Caching the proxy UGI prevents Hadoop{@literal '}s {@code 
FileSystem.CACHE} from accumulating a distinct
+   * entry (and its RPC/IPC resources) for every request; evicted entries are 
cleaned up through
+   * {@link FileSystem#closeAllForUGI(UserGroupInformation)}.</p>
+   * @param userName the effective user name extracted from the request
+   * @param loginUser the server login user that acts as the real user of the 
proxy
+   * @return the cached or freshly created proxy user
+   */
+  @VisibleForTesting
+  UserGroupInformation getProxyUser(String userName, UserGroupInformation 
loginUser) {
+    return proxyUserCache.get(new UgiKey(userName, loginUser.getUserName()), 
key -> {
+      LOG.debug("Creating proxy user for: {}", key.effectiveUser());
+      return UserGroupInformation.createProxyUser(key.effectiveUser(), 
loginUser);
+    });
+  }
+
+  /**
+   * Forces the proxy user cache to run any pending maintenance (eviction and 
cleanup).
+   */
+  @VisibleForTesting
+  void cleanUpProxyUserCache() {
+    proxyUserCache.cleanUp();
+  }
+
+  /**
+   * Releases resources held by this instance: stops the cache maintenance 
scheduler and runs any
+   * pending eviction cleanup. Should be called when the servlet is destroyed.
+   */
+  public void close() {
+    if (cacheMaintenanceExecutor != null) {
+      cacheMaintenanceExecutor.shutdownNow();
+    }
+  }
+
+  /**
+   * @return the approximate number of entries currently held in the proxy 
user cache
+   */
+  @VisibleForTesting
+  long proxyUserCacheSize() {
+    proxyUserCache.cleanUp();
+    return proxyUserCache.estimatedSize();
   }
 
   /**
@@ -173,6 +313,12 @@ public String getServletName() {
     public String getServletInfo() {
       return delegate.getServletInfo();
     }
+
+    @Override
+    public void destroy() {
+      ServletSecurity.this.close();
+      delegate.destroy();
+    }
   }
 
   /**
@@ -237,8 +383,7 @@ public void execute(HttpServletRequest request, 
HttpServletResponse response, Me
       // Temporary, and useless for now. Here only to allow this to work on an 
otherwise kerberized
       // server.
       if (isSecurityEnabled || authType == AuthType.JWT || authType == 
AuthType.OAUTH2) {
-        LOG.info("Creating proxy user for: {}", userFromHeader);
-        clientUgi = UserGroupInformation.createProxyUser(userFromHeader, 
UserGroupInformation.getLoginUser());
+        clientUgi = getProxyUser(userFromHeader, 
UserGroupInformation.getLoginUser());
       } else {
         // Unreachable in the case of NONE
         Preconditions.checkState(authType == AuthType.SIMPLE);
diff --git 
a/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/TestServletSecurity.java
 
b/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/TestServletSecurity.java
new file mode 100644
index 00000000000..e0146f8b6cf
--- /dev/null
+++ 
b/standalone-metastore/metastore-server/src/test/java/org/apache/hadoop/hive/metastore/TestServletSecurity.java
@@ -0,0 +1,102 @@
+/*
+ * 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.hive.metastore;
+
+import java.util.concurrent.TimeUnit;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.hive.metastore.annotation.MetastoreUnitTest;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars;
+import org.apache.hadoop.security.UserGroupInformation;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+/**
+ * Unit tests for the proxy {@link UserGroupInformation} cache used by {@link 
ServletSecurity} to bound the number of
+ * proxy UGIs (and their associated FileSystem resources) that would otherwise 
leak through Hadoop's FileSystem cache.
+ */
+@Category(MetastoreUnitTest.class)
+public class TestServletSecurity {
+
+  private static Configuration confWithCache(long maxSize, long expirySeconds) 
{
+    Configuration conf = MetastoreConf.newMetastoreConf();
+    MetastoreConf.setLongVar(conf, ConfVars.CATALOG_SERVLET_UGI_CACHE_SIZE, 
maxSize);
+    MetastoreConf.setTimeVar(conf, ConfVars.CATALOG_SERVLET_UGI_CACHE_EXPIRY, 
expirySeconds, TimeUnit.SECONDS);
+    return conf;
+  }
+
+  @Test
+  public void testProxyUserIsCachedPerUser() throws Exception {
+    ServletSecurity security = new 
ServletSecurity(ServletSecurity.AuthType.JWT, confWithCache(100, 3600));
+    UserGroupInformation loginUser = UserGroupInformation.getCurrentUser();
+
+    UserGroupInformation first = security.getProxyUser("alice", loginUser);
+    UserGroupInformation second = security.getProxyUser("alice", loginUser);
+
+    Assert.assertSame("Repeated requests for the same user must reuse the 
cached proxy UGI", first, second);
+    Assert.assertEquals("alice", first.getShortUserName());
+  }
+
+  @Test
+  public void testDistinctUsersGetDistinctProxies() throws Exception {
+    ServletSecurity security = new 
ServletSecurity(ServletSecurity.AuthType.JWT, confWithCache(100, 3600));
+    UserGroupInformation loginUser = UserGroupInformation.getCurrentUser();
+
+    UserGroupInformation alice = security.getProxyUser("alice", loginUser);
+    UserGroupInformation bob = security.getProxyUser("bob", loginUser);
+
+    Assert.assertNotSame(alice, bob);
+    Assert.assertEquals(2, security.proxyUserCacheSize());
+  }
+
+  @Test
+  public void testEvictionClosesFileSystemForUgi() throws Exception {
+    // A cache bounded to a single entry: inserting a second user evicts the 
first.
+    // Runnable::run makes removal-listener cleanup run synchronously on this 
thread, where the static mock is active.
+    ServletSecurity security =
+        new ServletSecurity(ServletSecurity.AuthType.JWT, confWithCache(1, 
3600), null, Runnable::run);
+    UserGroupInformation loginUser = UserGroupInformation.getCurrentUser();
+
+    try (MockedStatic<FileSystem> fsMock = 
Mockito.mockStatic(FileSystem.class)) {
+      UserGroupInformation alice = security.getProxyUser("alice", loginUser);
+      security.getProxyUser("bob", loginUser);
+      // Force any pending size-based eviction (and its synchronous cleanup) 
to run.
+      security.cleanUpProxyUserCache();
+
+      fsMock.verify(() -> FileSystem.closeAllForUGI(alice));
+    }
+  }
+
+  @Test
+  public void testExpiryDisabledWhenNonPositive() throws Exception {
+    // expiry == 0 disables time-based eviction; the size bound still applies 
and entries remain until displaced.
+    ServletSecurity security = new 
ServletSecurity(ServletSecurity.AuthType.JWT, confWithCache(100, 0));
+    UserGroupInformation loginUser = UserGroupInformation.getCurrentUser();
+
+    UserGroupInformation first = security.getProxyUser("carol", loginUser);
+    UserGroupInformation second = security.getProxyUser("carol", loginUser);
+
+    Assert.assertSame(first, second);
+  }
+}

Reply via email to