Skip to content

[#12403] fix(core): defer catalog wrapper cleanup with an operation lease - #12404

Open
yuqi1129 wants to merge 7 commits into
apache:mainfrom
yuqi1129:issue-12403-catalog-wrapper-lease
Open

[#12403] fix(core): defer catalog wrapper cleanup with an operation lease#12404
yuqi1129 wants to merge 7 commits into
apache:mainfrom
yuqi1129:issue-12403-catalog-wrapper-lease

Conversation

@yuqi1129

@yuqi1129 yuqi1129 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

  • CatalogManager.CatalogWrapper now counts active operations: tryAcquire() takes a lease, release() returns it, and retire() marks the wrapper unusable for new leases without tearing anything down. The catalog and the IsolatedClassLoader are cleaned up exactly once, when the wrapper is retired and its last lease has been released (cleanup runs outside the lock so a slow catalog close cannot block new lease attempts).
  • The catalog cache removal listener calls retire() instead of close(); close() is kept as an alias of retire() for exclusive owners such as testConnection.
  • New CatalogLease (AutoCloseable) obtained from CatalogManager.acquireCatalogLease(ident), which reloads a fresh wrapper (bounded retries) when the cached one has already retired. The lease methods on the wrapper stay package-private; every production caller lives in the catalog package.
  • All production uses migrated to the lease API: loadCatalog, enableCatalog, disableCatalog, alterCatalog, dropCatalog, getResolvedProperties, OperationDispatcher.doWithTable/doWithCatalog, CapabilityHelpers.getCapability and IcebergCleanupHelper.catalogId.
  • loadCatalogAndWrap stays for compatibility (documented as unleased) and now detects a stale cache entry via isRetired() instead of catalog() != null.

Why are the changes needed?

CatalogWrapper.close() was invoked from the cache removal listener, which Caffeine runs asynchronously and outside the local TreeLock. A cache expiry, a remote change-log invalidation, or a drop could therefore close the catalog, null its reference and release the pooled ClassLoader while another thread was still using the wrapper, surfacing as NullPointerException, NoClassDefFoundError, or errors from prematurely closed catalog resources.

Fix: #12403

One behaviour note for reviewers: when a wrapper is evicted while an operation is in flight, the deferred cleanup runs on the thread that releases the last lease, i.e. a request thread, so a slow catalog close is charged to that request. In the common case (no lease at eviction time) cleanup still runs on the cache's own thread. Offloading the deferred cleanup to an executor is a possible follow-up; it was left out here to keep the change small.

Does this PR introduce any user-facing change?

No. New internal APIs only (CatalogLease, and package-private lease methods on CatalogWrapper); no configuration or REST API change.

How was this patch tested?

  • New TestCatalogWrapperLease (8 tests): cache expiry, explicit invalidation, remote change-log invalidation, drop, retired-wrapper reload, exactly-once ClassLoader cleanup (verified through the shared pool reference count), release-without-acquire, and a two-thread repro where the cache is invalidated while an operation holds the wrapper.
  • Negative check: making cleanup eager again (the old behaviour) fails 5 of the 8 tests, including the concurrent repro.
  • ./gradlew :core:test :server:test :iceberg:iceberg-rest-server:test -PskipITs all pass (core: 1618 tests); repo-wide compileJava/compileTestJava, spotlessCheck and javadoc are clean.

…ions

CatalogManager closed CatalogWrapper synchronously from the catalog cache
removal listener. Caffeine runs that listener asynchronously and outside the
local TreeLock, so an expiry, a remote change-log invalidation or a drop could
close the catalog, clear its reference and release the pooled ClassLoader while
another thread was still running an operation on that wrapper.

CatalogWrapper now counts active operations: tryAcquire() takes a lease,
release() returns it and retire() (called from the removal listener) only marks
the wrapper unusable for new leases. The catalog and the ClassLoader are cleaned
up exactly once, when the wrapper is retired and its last lease is released.
Operations obtain a CatalogLease from CatalogManager.acquireCatalogLease(),
which reloads a fresh wrapper when the cached one has already retired, and all
production uses of the wrapper were migrated to it.
Copilot AI lite review requested due to automatic review settings August 10, 2026 10:29

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

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR introduces an operation-lease mechanism around cached CatalogWrapper instances to prevent cache eviction/removal listeners from tearing down catalogs/classloaders while operations are still in-flight.

Changes:

  • Added CatalogLease and CatalogManager#acquireCatalogLease(...) to keep wrappers alive during operations and reload retired wrappers with bounded retries.
  • Updated production callers to use try-with-resources leasing instead of directly using loadCatalogAndWrap(...).
  • Expanded unit test coverage to validate deferred cleanup, retired-wrapper reload, and concurrency scenarios.

Reviewed changes

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

Show a summary per file
File Description
iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergAsyncPurge.java Mocks the new lease acquisition path in REST server tests
iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergCleanupHelper.java Uses CatalogLease to safely access catalog ID
core/src/test/java/org/apache/gravitino/hook/TestTopicHookDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/hook/TestTableHookDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/hook/TestSchemaHookDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/hook/TestModelHookDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/hook/TestFunctionHookDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/hook/TestFilesetHookDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/catalog/TestTableNormalizeDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/catalog/TestPartitionNormalizeDispatcher.java Updates mocks to support leasing
core/src/test/java/org/apache/gravitino/catalog/TestFunctionOperationDispatcher.java Updates mocks to support leasing for multiple catalogs
core/src/test/java/org/apache/gravitino/catalog/TestCatalogWrapperLease.java New tests validating deferred cleanup and concurrent eviction safety
core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java Adapts tests to wrapper retirement / tryAcquire semantics
core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java Uses leases for catalog/table operations to avoid use-after-eviction
core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java Implements wrapper lease counting, retirement, and deferred cleanup
core/src/main/java/org/apache/gravitino/catalog/CatalogLease.java New AutoCloseable lease implementation
core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java Uses leases when fetching capabilities
Suppressed comments (1)

core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java:1

  • catalog() reads catalog without synchronization/volatile, while cleanup() mutates catalog (and poolEntry) outside of leaseLock. This is a Java memory-model data race and can lead to visibility issues or observing partially-updated state across threads. A concrete fix is to either (a) make the mutated/read fields (catalog, and potentially poolEntry/classLoader) volatile, and/or (b) move the state-nullification (catalog = null, poolEntry = null, etc.) into a synchronized (leaseLock) block after closing resources so publication is properly ordered.
/*

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread core/src/main/java/org/apache/gravitino/catalog/CatalogLease.java Outdated
Comment thread core/src/test/java/org/apache/gravitino/catalog/TestCatalogWrapperLease.java Outdated
Comment thread core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
…vate

Drop CatalogLease.of(), which existed only so tests with a mocked
CatalogManager could hand out a lease, and narrow tryAcquire/release/retire/
isRetired to package-private: every production caller lives in the catalog
package. Tests outside that package now build leases through a test-only
CatalogTestUtils helper.

Also document that the deferred cleanup runs on the thread that releases the
last lease, which is a request thread when the wrapper was evicted while an
operation was in flight.
…tCapability

CapabilityHelpers.getCapability() acquired the catalog lease inside the try
block that wraps failures into a RuntimeException, so a missing catalog turned
a 404 into a 500 on the normalize paths. Acquire the lease outside the try, as
the catalog load was before, and wrap only the capability lookup.
- Make CatalogWrapper#catalog volatile so the cleanup that nulls it outside
  leaseLock is properly published to unleased readers (loadCatalogAndWrap
  callers); keep the nulling out of the lock so a slow catalog close cannot
  stall tryAcquire. Make classLoader and pool final, they never change after
  construction.
- Keep the Javadoc @link to acquireCatalogLease on one line by importing
  NameIdentifier, so the link renders.
- Build the expiring test's SecretManager from the same config as its
  CatalogManager.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Code Coverage Report

Overall Project 68.59% +0.06% 🟢
Files changed 71.28% 🟢

Module Coverage
aliyun 1.72% 🔴
api 49.31% 🟢
authorization-common 85.96% 🟢
aws 42.04% 🟢
azure 2.47% 🔴
catalog-common 9.92% 🔴
catalog-fileset 79.77% 🟢
catalog-glue 68.95% 🟢
catalog-hive 79.4% 🟢
catalog-jdbc-common 45.7% 🟢
catalog-jdbc-doris 81.8% 🟢
catalog-jdbc-mysql 79.33% 🟢
catalog-jdbc-postgresql 83.39% 🟢
catalog-jdbc-starrocks 79.16% 🟢
catalog-kafka 77.01% 🟢
catalog-lakehouse-generic 59.18% 🟢
catalog-lakehouse-hudi 79.1% 🟢
catalog-lakehouse-iceberg 85.86% 🟢
catalog-lakehouse-paimon 84.23% 🟢
catalog-model 77.72% 🟢
cli 44.48% 🟢
client-java 78.46% 🟢
common 52.72% 🟢
core 83.39% -0.65% 🟢
filesystem-hadoop3 77.28% 🟢
flink 0.0% 🔴
flink-common 48.68% 🟢
flink-runtime 0.0% 🔴
gcp 14.12% 🔴
hadoop-auth 68.0% 🟢
hadoop-common 12.7% 🔴
hive-metastore-common 53.4% 🟢
iceberg-aliyun-bundle 0.0% 🔴
iceberg-common 64.75% 🟢
iceberg-rest-server 75.05% -0.97% 🟢
idp-basic 86.02% 🟢
integration-test-common 0.0% 🔴
jobs 62.92% 🟢
lance-common 31.75% 🔴
lance-rest-server 63.47% 🟢
lineage 53.02% 🟢
optimizer 83.24% 🟢
optimizer-api 21.95% 🔴
server 87.38% 🟢
server-common 79.34% 🟢
spark 28.57% 🔴
spark-common 45.89% 🟢
tencent 69.84% 🟢
trino-connector 40.29% 🟢
Files
Module File Coverage
core CatalogLease.java 100.0% 🟢
OperationDispatcher.java 93.75% 🟢
ClassLoaderPool.java 86.05% 🟢
CapabilityHelpers.java 78.87% 🟢
CatalogManager.java 71.76% 🟢
CaffeineEntityCache.java 71.29% 🟢
AuthorizationUtils.java 67.31% 🟢
CatalogHookDispatcher.java 34.55% 🔴
iceberg-rest-server IcebergCleanupHelper.java 100.0% 🟢
DynamicIcebergConfigProvider.java 42.31% 🔴

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 19 out of 19 changed files in this pull request and generated no new comments.

Suppressed comments (1)

core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java:425

  • cleanup() can leave catalog non-null if catalog.close() throws, because the assignment catalog = null is after the close call inside the lambda. Since cleanupStarted is then set, cleanup won’t be retried, leaving a stale reference (and potentially resources) around even though the wrapper is retired.
        classLoader.withClassLoader(
            cl -> {
              if (catalog != null) {
                catalog.close();
              }

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug report] Catalog cache eviction can close a catalog while an operation is using it

2 participants