Skip to content

HIVE-29818: ServletSecurity needs a UGI cache - #6704

Open
henrib wants to merge 4 commits into
apache:masterfrom
henrib:HIVE-29818
Open

HIVE-29818: ServletSecurity needs a UGI cache#6704
henrib wants to merge 4 commits into
apache:masterfrom
henrib:HIVE-29818

Conversation

@henrib

@henrib henrib commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

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.

This PR caches the proxy UGI in ServletSecurity using 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 expiry)

A note in the code documents why eviction-while-in-use is not reference-counted: eviction is idle-based (expireAfterAccess) and both the expiry window and max size are expected to be kept well above the longest operation / peak concurrent distinct users.

Why are the changes needed?

To prevent the OutOfMemoryError caused by unbounded accumulation of proxy UGIs and their associated FileSystem/IPC resources in long-running REST Catalog deployments.

Does this PR introduce any user-facing change?

Two new (optional) metastore configuration properties, both with sensible defaults.

How was this patch tested?

Added TestServletSecurity covering per-user caching, distinct proxies per user, eviction-triggered FileSystem.closeAllForUGI cleanup, and disabled expiry. All 4 tests pass.

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.
Copilot AI lite review requested due to automatic review settings August 17, 2026 15:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (4)

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:123

  • The UgiKey field name realUser is misleading given the Javadoc: this value represents the effective/proxy user being impersonated, while the real user is the login user. Renaming the record components to something like effectiveUser and loginUser (or loginUserName) would reduce confusion and make logs like key.realUser() accurate.
  /**
   * 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 realUser, String loginUser) {}

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:137

  • Casting the configured cache size from long to int can truncate large configured values (or wrap negative), leading to an incorrect maximumSize and unexpected behavior. Consider keeping this as a long end-to-end (Caffeine’s maximumSize accepts a long) and validating the value (e.g., reject negatives).
    this.proxyUserCache = createCacheWithConfig(
        MetastoreConf.getTimeVar(conf, MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_EXPIRY, TimeUnit.MILLISECONDS),
        (int) MetastoreConf.getLongVar(conf, MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_SIZE));

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:170

  • Using .executor(Runnable::run) makes the removal listener run inline on the calling thread (likely a request thread). Since the listener calls FileSystem.closeAllForUGI, eviction can add noticeable latency or block request handling during bursts/evictions. Consider using the default executor or a dedicated bounded executor for removals so cleanup work doesn’t run on latency-sensitive threads.
    Caffeine<UgiKey, UserGroupInformation> builder = Caffeine.<UgiKey, UserGroupInformation>newBuilder()
        .maximumSize(maxSize)
        .executor(Runnable::run)
        .removalListener(cleanupListener);

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:196

  • Logging the effective username at INFO can leak user identity information into logs and may be considered sensitive in some deployments. Since this log happens for cache misses (and potentially many distinct users), consider downgrading it to DEBUG or making it configurable/redacted.
  UserGroupInformation getProxyUser(String userName, UserGroupInformation loginUser) {
    return proxyUserCache.get(new UgiKey(userName, loginUser.getUserName()), key -> {
      LOG.info("Creating proxy user for: {}", key.realUser());
      return UserGroupInformation.createProxyUser(key.realUser(), loginUser);
    });
  }

@ayushtkn ayushtkn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanx @henrib this overall looks good to me, there are some suppressed comments from co-pilot but I feel they are minor and maybe worth addressing, can u give a check once

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:123

    The UgiKey field name realUser is misleading given the Javadoc: this value represents the effective/proxy user being impersonated, while the real user is the login user. Renaming the record components to something like effectiveUser and loginUser (or loginUserName) would reduce confusion and make logs like key.realUser() accurate.

  /**
   * 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 realUser, String loginUser) {}

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:137

    Casting the configured cache size from long to int can truncate large configured values (or wrap negative), leading to an incorrect maximumSize and unexpected behavior. Consider keeping this as a long end-to-end (Caffeine’s maximumSize accepts a long) and validating the value (e.g., reject negatives).

    this.proxyUserCache = createCacheWithConfig(
        MetastoreConf.getTimeVar(conf, MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_EXPIRY, TimeUnit.MILLISECONDS),
        (int) MetastoreConf.getLongVar(conf, MetastoreConf.ConfVars.CATALOG_SERVLET_UGI_CACHE_SIZE));

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:170

    Using .executor(Runnable::run) makes the removal listener run inline on the calling thread (likely a request thread). Since the listener calls FileSystem.closeAllForUGI, eviction can add noticeable latency or block request handling during bursts/evictions. Consider using the default executor or a dedicated bounded executor for removals so cleanup work doesn’t run on latency-sensitive threads.

    Caffeine<UgiKey, UserGroupInformation> builder = Caffeine.<UgiKey, UserGroupInformation>newBuilder()
        .maximumSize(maxSize)
        .executor(Runnable::run)
        .removalListener(cleanupListener);

standalone-metastore/metastore-server/src/main/java/org/apache/hadoop/hive/metastore/ServletSecurity.java:196

    Logging the effective username at INFO can leak user identity information into logs and may be considered sensitive in some deployments. Since this log happens for cache misses (and potentially many distinct users), consider downgrading it to DEBUG or making it configurable/redacted.

  UserGroupInformation getProxyUser(String userName, UserGroupInformation loginUser) {
    return proxyUserCache.get(new UgiKey(userName, loginUser.getUserName()), key -> {
      LOG.info("Creating proxy user for: {}", key.realUser());
      return UserGroupInformation.createProxyUser(key.realUser(), loginUser);
    });
  }

- 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

@saihemanth-cloudera saihemanth-cloudera left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall patch looks good to me. A couple of points to think about.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we can potentially see failures for in-flight REST catalog operations.
How about we keep tracking of active users and deferring cleanup until the last request exits, or using any another lifecycle model that never closes a UGI currently in use?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. The design deliberately avoids reference counting: the eviction policy is expireAfterAccess, so an entry cannot expire while it is still being accessed. The code comment at the site documents the accepted tradeoff — both the expiry window and the max cache size are expected to be kept comfortably above the longest operation and peak concurrent distinct-user count, making the scenario where an eviction races an active doAs a practical non-issue. Adding reference counts would add significant complexity for a case that requires a misconfigured cache (size or expiry too tight relative to workload) to trigger.

.removalListener(cleanupListener);

if (expirationMs > 0) {
builder.expireAfterAccess(Duration.ofMillis(expirationMs))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is edge case but worth thinking about: A burst of distinct proxy users followed by an idle period can keep the UGI/FileSystem resources alive well past metastore.catalog.servlet.ugi.cache.expiry
Since the feature is specifically about idle cleanup, should we think about adding a scheduler or a servlet-owned maintenance task that periodically calls cleanUp()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest commit. The production constructor now schedules proxyUserCache::cleanUp() on the dedicated maintenance executor at the configured expiry interval, so entries accumulated during a burst are reaped after they expire even when no subsequent traffic arrives to trigger Caffeine's access-piggybacked maintenance.

(key, ugi, cause) -> {
if (ugi != null) {
try {
FileSystem.closeAllForUGI(ugi);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can perform blocking filesystem cleanup. Because we are invoking this on ForkJoinPool.commonPool() running that on the JVM common pool risks interfering with unrelated async work.
Should a small dedicated executor owned by ServletSecurity or the metastore service would be a safer appraoch, with lifecycle shutdown?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest commit. Replaced ForkJoinPool.commonPool() with a dedicated newSingleThreadScheduledExecutor running a named daemon thread ("ugi-cache-cleanup"), which isolates the blocking FileSystem.closeAllForUGI calls from the JVM-wide shared pool. The same executor also drives the periodic cleanUp() schedule added for the burst scenario.

…dicated 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).
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants