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

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


The following commit(s) were added to refs/heads/master by this push:
     new 69b803b32a5 [refactor](paimon) Replace Paimon SDK CachingCatalog with 
PaimonMetaCacheCatalog (#67996)
69b803b32a5 is described below

commit 69b803b32a5ec914a4f4bfdaf45f6347c48435a0
Author: 924060929 <[email protected]>
AuthorDate: Mon Sep 21 17:41:41 2026 +0800

    [refactor](paimon) Replace Paimon SDK CachingCatalog with 
PaimonMetaCacheCatalog (#67996)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    The Paimon SDK `CachingCatalog` wraps every catalog by default and
    caches
    Table objects with frozen schema/snapshot state, exposing only per-table
    `invalidateTable(Identifier)` — no db/catalog-level eviction. After an
    external same-name drop/recreate of a Paimon table, `REFRESH TABLE` only
    flushes Doris-owned caches; the SDK's frozen Table survives and keeps
    serving stale data
    
    This PR replaces the SDK `CachingCatalog` entirely with a new
    `PaimonMetaCacheCatalog` (extends `DelegateCatalog`) that places every
    catalog-level cache inside Doris's own `CatalogMetaCache` framework so
    `REFRESH TABLE/DATABASE/CATALOG` invalidates them through the same
    registry path as every other connector-owned cache.
    
    ### Design
    
    `cache-enabled=false` is forced unconditionally — PaimonMetaCacheCatalog
    and the SDK CachingCatalog cannot coexist: a Doris-side MetaCache miss
    falls through to `super.getTable()` which hits the SDK cache and returns
    a frozen Table, so the stale-read bug persists even after REFRESH.
    
    The `hasEnclosingMetaCacheWeightLimit` parameter is retained in the API
    signature for compatibility but is a no-op (the SDK cache is always
    off).
    
    Key cache layers provided by PaimonMetaCacheCatalog:
    - `tableCache` → `MetaCache<Identifier,Table>` (scope: table)
    - `databaseCache` → `MetaCache<String,Database>` (scope: database)
    - per-FileStoreTable caches (snapshot/stats/manifest) retained on load
    
    ### Release note
    
    Fix stale reads after an external drop/recreate of a same-name Paimon
    table:
    Doris-side `REFRESH TABLE` now fully invalidates the Paimon SDK table
    cache.
---
 .../doris/connector/cache/CatalogMetaCache.java    |  15 +
 .../apache/doris/connector/cache/MetaCache.java    |   4 +
 .../connector/paimon/PaimonCacheSizeEstimator.java | 163 +++++
 .../connector/paimon/PaimonCatalogFactory.java     |  17 +-
 .../doris/connector/paimon/PaimonCatalogOps.java   |  13 +-
 .../doris/connector/paimon/PaimonConnector.java    |  15 +-
 .../connector/paimon/PaimonMetaCacheCatalog.java   | 410 +++++++++++++
 .../connector/paimon/PaimonCatalogFactoryTest.java |  29 +-
 .../paimon/PaimonCatalogOptionsSnapshotTest.java   |  32 +-
 .../paimon/PaimonMetaCacheCatalogTest.java         | 679 +++++++++++++++++++++
 .../paimon/PaimonRestCatalogPartitionsTest.java    |  20 +
 11 files changed, 1361 insertions(+), 36 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CatalogMetaCache.java
 
b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CatalogMetaCache.java
index 84b5022952e..d84c389b04b 100644
--- 
a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CatalogMetaCache.java
+++ 
b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CatalogMetaCache.java
@@ -132,6 +132,21 @@ public final class CatalogMetaCache implements 
AutoCloseable {
         }
     }
 
+    /**
+     * Removes one cache registration if it is still owned by this catalog. 
This is intended for
+     * rolling back a multi-step connector construction without closing 
unrelated sibling caches.
+     */
+    public void remove(MetaCache<?, ?> cache) {
+        MetaCache<?, ?> nonNullCache = Objects.requireNonNull(cache, "cache 
can not be null");
+        if (entries.remove(nonNullCache.name(), nonNullCache)) {
+            try {
+                nonNullCache.closeFromOwner();
+            } finally {
+                names.remove(nonNullCache.name());
+            }
+        }
+    }
+
     public void invalidateCatalog() {
         registry.invalidate(ScopePath.catalog());
     }
diff --git 
a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCache.java
 
b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCache.java
index 1447fd5a83f..07afd00576d 100644
--- 
a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCache.java
+++ 
b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/MetaCache.java
@@ -135,6 +135,10 @@ public final class MetaCache<K, V> {
         delegate.forEach(consumer);
     }
 
+    void closeFromOwner() {
+        delegate.close();
+    }
+
     public static final class BulkLoad<K, V> implements AutoCloseable {
         private final MetaCache<K, V> owner;
         private final ScopedMetaCache.BulkLoadHandle delegate;
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCacheSizeEstimator.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCacheSizeEstimator.java
new file mode 100644
index 00000000000..ffd4cdfe10d
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCacheSizeEstimator.java
@@ -0,0 +1,163 @@
+// 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.doris.connector.paimon;
+
+import org.apache.doris.connector.cache.JvmSizeUtils;
+import org.apache.doris.connector.cache.MetaCacheSizeEstimate;
+import org.apache.doris.connector.cache.ReflectiveObjectSizeEstimator;
+
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.privilege.PrivilegedFileStoreTable;
+import org.apache.paimon.table.CatalogEnvironment;
+import org.apache.paimon.table.DelegatedFileStoreTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.iceberg.IcebergTable;
+import org.apache.paimon.table.lance.LanceTable;
+import org.apache.paimon.table.object.ObjectTable;
+
+import java.net.URI;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.Set;
+
+/**
+ * Retained-size formulas for Paimon table-cache entries.
+ *
+ * <p>The table's shallow size includes references to FileIO, catalog loaders, 
and lock factories,
+ * but their graphs are catalog-scoped executable services rather than 
entry-owned metadata. Walking
+ * those graphs both double-counts shared state and reaches strongly 
encapsulated JDK objects. The
+ * estimator therefore expands only immutable metadata owned by the entry.
+ */
+final class PaimonCacheSizeEstimator {
+    private PaimonCacheSizeEstimator() {
+    }
+
+    static MetaCacheSizeEstimate estimateTable(Identifier key, Table table, 
long entryOverheadBytes) {
+        if (table instanceof PrivilegedFileStoreTable) {
+            return MetaCacheSizeEstimate.incomplete(
+                    "authorization decorators must be applied outside the 
metadata cache");
+        }
+        long bytes = add(entryOverheadBytes, 
ReflectiveObjectSizeEstimator.estimateComplete(key));
+        if (table instanceof FileStoreTable) {
+            Set<Object> visited = Collections.newSetFromMap(new 
IdentityHashMap<>());
+            bytes = add(bytes, estimateFileStoreTable((FileStoreTable) table, 
visited));
+        } else {
+            if (!isSupportedNonFileStoreTable(table)) {
+                return MetaCacheSizeEstimate.incomplete(
+                        "unsupported retained graph for " + 
table.getClass().getName());
+            }
+            bytes = add(bytes, JvmSizeUtils.instanceSize(table.getClass()));
+            bytes = add(bytes, 
ReflectiveObjectSizeEstimator.estimateComplete(table.rowType()));
+            bytes = add(bytes, 
ReflectiveObjectSizeEstimator.estimateComplete(table.partitionKeys()));
+            bytes = add(bytes, 
ReflectiveObjectSizeEstimator.estimateComplete(table.primaryKeys()));
+            bytes = add(bytes, 
ReflectiveObjectSizeEstimator.estimateComplete(table.options()));
+            bytes = add(bytes, 
ReflectiveObjectSizeEstimator.estimateComplete(table.comment()));
+            bytes = add(bytes, JvmSizeUtils.stringSize(location(table)));
+        }
+        return MetaCacheSizeEstimate.complete(bytes);
+    }
+
+    private static boolean isSupportedNonFileStoreTable(Table table) {
+        return table instanceof FormatTable
+                || table instanceof ObjectTable
+                || table instanceof LanceTable
+                || table instanceof IcebergTable;
+    }
+
+    private static long estimateFileStoreTable(FileStoreTable table, 
Set<Object> visited) {
+        if (!visited.add(table)) {
+            return 0L;
+        }
+        long bytes = JvmSizeUtils.instanceSize(table.getClass());
+        if (table instanceof FallbackReadFileStoreTable) {
+            FallbackReadFileStoreTable fallback = (FallbackReadFileStoreTable) 
table;
+            bytes = add(bytes, estimateFileStoreTable(fallback.wrapped(), 
visited));
+            return add(bytes, estimateFileStoreTable(fallback.fallback(), 
visited));
+        }
+        if (table instanceof DelegatedFileStoreTable) {
+            return add(bytes, estimateFileStoreTable(
+                    ((DelegatedFileStoreTable) table).wrapped(), visited));
+        }
+        bytes = add(bytes, estimateCompleteOnce(table.schema(), visited));
+        bytes = add(bytes, estimatePath(table.location(), visited));
+        return add(bytes, 
estimateCatalogEnvironment(table.catalogEnvironment(), visited));
+    }
+
+    private static long estimateCompleteOnce(Object value, Set<Object> 
visited) {
+        return value == null || !visited.add(value)
+                ? 0L : ReflectiveObjectSizeEstimator.estimateComplete(value);
+    }
+
+    private static long estimateCatalogEnvironment(CatalogEnvironment 
environment, Set<Object> visited) {
+        if (environment == null || !visited.add(environment)) {
+            return 0L;
+        }
+        long bytes = JvmSizeUtils.instanceSize(environment.getClass());
+        bytes = add(bytes, estimateCompleteOnce(environment.identifier(), 
visited));
+        return add(bytes, estimateStringOnce(environment.uuid(), visited));
+    }
+
+    private static long estimatePath(Path path, Set<Object> visited) {
+        if (path == null || !visited.add(path)) {
+            return 0L;
+        }
+        URI uri = path.toUri();
+        long bytes = JvmSizeUtils.instanceSize(path.getClass());
+        if (visited.add(uri)) {
+            bytes = add(bytes, JvmSizeUtils.instanceSize(uri.getClass()));
+            bytes = add(bytes, estimateStringOnce(uri.toString(), visited));
+            bytes = add(bytes, estimateStringOnce(uri.getScheme(), visited));
+            bytes = add(bytes, estimateStringOnce(uri.getUserInfo(), visited));
+            bytes = add(bytes, estimateStringOnce(uri.getHost(), visited));
+            bytes = add(bytes, estimateStringOnce(uri.getPath(), visited));
+            bytes = add(bytes, estimateStringOnce(uri.getQuery(), visited));
+            bytes = add(bytes, estimateStringOnce(uri.getFragment(), visited));
+            bytes = add(bytes, estimateStringOnce(uri.getAuthority(), 
visited));
+            bytes = add(bytes, estimateStringOnce(uri.getSchemeSpecificPart(), 
visited));
+        }
+        return bytes;
+    }
+
+    private static long estimateStringOnce(String value, Set<Object> visited) {
+        return value == null || !visited.add(value) ? 0L : 
JvmSizeUtils.stringSize(value);
+    }
+
+    private static String location(Table table) {
+        if (table instanceof FormatTable) {
+            return ((FormatTable) table).location();
+        }
+        if (table instanceof ObjectTable) {
+            return ((ObjectTable) table).location();
+        }
+        if (table instanceof LanceTable) {
+            return ((LanceTable) table).location();
+        }
+        if (table instanceof IcebergTable) {
+            return ((IcebergTable) table).location();
+        }
+        return null;
+    }
+
+    private static long add(long left, long right) {
+        return JvmSizeUtils.saturatedAdd(left, right);
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java
index 3e52564feec..e6c96cdab9d 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java
@@ -103,11 +103,19 @@ public final class PaimonCatalogFactory {
      * plus each flavor's {@code appendCustomCatalogOptions()}.
      */
     public static Options buildCatalogOptions(PaimonCatalogProperties 
catalogProperties) {
-        return buildCatalogOptions(catalogProperties, false);
+        Options options = assembleCatalogOptions(catalogProperties);
+        // PaimonMetaCacheCatalog replaces the SDK CachingCatalog. The two 
cache layers cannot
+        // coexist because a Doris invalidation cannot evict a frozen Table 
from the SDK wrapper.
+        // Preserve the user's flag separately, but always disable the SDK 
wrapper itself.
+        options.set(CatalogOptions.CACHE_ENABLED, false);
+        return options;
+    }
+
+    static boolean isCatalogCacheEnabled(PaimonCatalogProperties 
catalogProperties) {
+        return 
assembleCatalogOptions(catalogProperties).get(CatalogOptions.CACHE_ENABLED);
     }
 
-    static Options buildCatalogOptions(
-            PaimonCatalogProperties catalogProperties, boolean 
hasEnclosingMetaCacheWeightLimit) {
+    private static Options assembleCatalogOptions(PaimonCatalogProperties 
catalogProperties) {
         Options options = new Options();
         Map<String, String> props = catalogProperties.getRaw();
         String flavor = catalogProperties.getFlavor();
@@ -141,9 +149,6 @@ public final class PaimonCatalogFactory {
                 // filesystem: nothing custom.
                 break;
         }
-        if (hasEnclosingMetaCacheWeightLimit && 
!options.contains(CatalogOptions.CACHE_ENABLED)) {
-            options.set(CatalogOptions.CACHE_ENABLED, false);
-        }
         return options;
     }
 
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
index b52ffd8f360..584eb9f7e83 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogOps.java
@@ -22,6 +22,7 @@ import org.apache.paimon.Snapshot;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.CatalogUtils;
 import org.apache.paimon.catalog.Database;
+import org.apache.paimon.catalog.DelegateCatalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.partition.Partition;
 import org.apache.paimon.privilege.PrivilegedFileStoreTable;
@@ -304,25 +305,29 @@ public interface PaimonCatalogOps {
         public Table getTable(Identifier identifier) throws 
Catalog.TableNotExistException {
             Table table = catalog.getTable(identifier);
             Map<String, String> optionsForCopy = 
PaimonTableOptions.forCopy(tableOptions);
-            // Relation options are applied after this cached handle is 
returned. Defer final
-            // validation so a safe relation value can override an unsafe 
physical value.
             return optionsForCopy.isEmpty() ? table : 
table.copy(optionsForCopy);
         }
 
         @Override
         public List<Partition> listPartitions(Identifier identifier, Table 
table)
                 throws Catalog.TableNotExistException {
-            if (catalog instanceof RESTCatalog) {
+            RESTCatalog restCatalog = restCatalog(catalog);
+            if (restCatalog != null) {
                 // REST owns partition visibility when its endpoint is 
implemented; the bridge
                 // retains this effective relation copy only for the 
endpoint's filesystem fallback.
                 return PaimonRestCatalogPartitions.listPartitions(
-                        (RESTCatalog) catalog, identifier, table);
+                        restCatalog, identifier, table);
             }
             // The supplied handle already contains catalog and relation 
policy. Reloading by identifier
             // would discard those copies before manifest enumeration reaches 
the final scan guard.
             return CatalogUtils.listPartitionsFromFileSystem(table);
         }
 
+        static RESTCatalog restCatalog(Catalog catalog) {
+            Catalog rootCatalog = DelegateCatalog.rootCatalog(catalog);
+            return rootCatalog instanceof RESTCatalog ? (RESTCatalog) 
rootCatalog : null;
+        }
+
         @Override
         public void createDatabase(String name, boolean ignoreIfExists, 
Map<String, String> properties)
                 throws Catalog.DatabaseAlreadyExistException {
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
index b00efbcb577..a47f90e0951 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
@@ -50,7 +50,6 @@ import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.hive.conf.HiveConf;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
-import org.apache.paimon.catalog.CachingCatalog;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.CatalogContext;
 import org.apache.paimon.catalog.CatalogFactory;
@@ -60,7 +59,6 @@ import org.apache.paimon.hive.HiveCatalog;
 import org.apache.paimon.hive.HiveCatalogOptions;
 import org.apache.paimon.options.CatalogOptions;
 import org.apache.paimon.options.Options;
-import org.apache.paimon.privilege.PrivilegedCatalog;
 
 import java.io.IOException;
 import java.io.UncheckedIOException;
@@ -499,7 +497,7 @@ public class PaimonConnector implements Connector {
     }
 
     Options buildCatalogOptions() {
-        return PaimonCatalogFactory.buildCatalogOptions(catalogProps, 
metaCache.hasEnclosingWeightLimit());
+        return PaimonCatalogFactory.buildCatalogOptions(catalogProps);
     }
 
     /**
@@ -543,11 +541,15 @@ public class PaimonConnector implements Connector {
         try {
             
Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
             return context.executeAuthenticated(() -> {
+                // PaimonMetaCacheCatalog installs PrivilegedCatalog after the 
raw metadata cache.
                 Catalog catalog = PaimonCatalogProperties.HMS.equals(flavor)
                         ? createHmsCatalog(catalogContext, hmsAuth, 
catalogProps.getRaw(),
                                 storageHadoopConfig)
-                        : CatalogFactory.createCatalog(catalogContext);
-                return catalog;
+                        : 
CatalogFactory.createUnwrappedCatalog(catalogContext, 
getClass().getClassLoader());
+                return PaimonMetaCacheCatalog.tryToCreate(catalog, metaCache,
+                        DEFAULT_TABLE_CACHE_CAPACITY, 
resolveTableCacheTtlSecond(catalogProps.getRaw()),
+                        catalogContext.options(), 
PaimonCatalogFactory.isCatalogCacheEnabled(catalogProps),
+                        metaCache.hasEnclosingWeightLimit());
             });
         } catch (Exception e) {
             throw new RuntimeException(failureMessage + " (flavor=" + flavor + 
"): " + e.getMessage(), e);
@@ -579,8 +581,7 @@ public class PaimonConnector implements Connector {
                             fileIO, hiveConf, clientClass, options, 
warehousePath.toUri().toString()));
             catalog = PaimonHmsClientPool.install(catalog, hmsAuth);
             catalog = PaimonHmsCatalog.install(catalog, properties, 
storageHadoopConfig);
-            catalog = CachingCatalog.tryToCreate(catalog, options);
-            return PrivilegedCatalog.tryToCreate(catalog, options);
+            return catalog;
         } catch (IOException e) {
             throw new UncheckedIOException(e);
         }
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonMetaCacheCatalog.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonMetaCacheCatalog.java
new file mode 100644
index 00000000000..b537b6f1e13
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonMetaCacheCatalog.java
@@ -0,0 +1,410 @@
+// 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.doris.connector.paimon;
+
+import org.apache.doris.connector.cache.CacheSpec;
+import org.apache.doris.connector.cache.CatalogMetaCache;
+import org.apache.doris.connector.cache.JvmSizeUtils;
+import org.apache.doris.connector.cache.MetaCache;
+import org.apache.doris.connector.cache.MetaCacheDefinition;
+import org.apache.doris.connector.cache.MetaCacheSizeEstimators;
+import org.apache.doris.connector.cache.ScopePath;
+
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.CatalogLoader;
+import org.apache.paimon.catalog.Database;
+import org.apache.paimon.catalog.DelegateCatalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.catalog.PropertyChange;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.options.CatalogOptions;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.privilege.PrivilegedCatalog;
+import org.apache.paimon.schema.SchemaChange;
+import 
org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.Caffeine;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.system.SystemTableLoader;
+import org.apache.paimon.utils.SegmentsCache;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.function.LongSupplier;
+
+/**
+ * Doris-owned replacement for Paimon's {@code CachingCatalog}. Table and 
database entries live in
+ * {@link CatalogMetaCache}, so a Doris catalog/database/table invalidation 
fences every matching
+ * in-flight load and cached value.
+ *
+ * <p>The cache retains raw table metadata. Paimon's privilege catalog is 
applied outside this
+ * wrapper so every lookup receives a fresh checker instead of caching one 
authorization snapshot.
+ *
+ * <p>The user's {@code paimon.cache-enabled} and access/write expiry settings 
remain authoritative.
+ * The Paimon SDK wrapper itself is disabled because a second hidden table 
cache cannot participate
+ * in Doris invalidation. Under a Doris weight budget, mutable SDK 
snapshot/stats/manifest caches are
+ * not attached: their post-publication growth cannot be reweighed by the 
enclosing budget.
+ */
+final class PaimonMetaCacheCatalog extends DelegateCatalog {
+
+    private static final int DATABASE_CACHE_CAPACITY = 100;
+    static final long TABLE_ENTRY_OVERHEAD_BYTES = JvmSizeUtils.saturatedAdd(
+            JvmSizeUtils.instanceSize(ExpiringValue.class), 
JvmSizeUtils.instanceSize(AtomicLong.class));
+
+    private final CatalogMetaCache metaCache;
+    private final MetaCache<Identifier, ExpiringValue<Table>> tableCache;
+    private final MetaCache<String, ExpiringValue<Database>> databaseCache;
+    private final SegmentsCache<Path> manifestCache;
+    private final long tableExpireAfterAccessNanos;
+    private final long databaseExpireAfterAccessNanos;
+    private final long expireAfterWriteNanos;
+    private final int snapshotMaxNumPerTable;
+    private final boolean attachSdkCaches;
+    private final LongSupplier nanoTime;
+    private final BiConsumer<String, Object> cacheMissObserver;
+
+    static Catalog tryToCreate(Catalog wrapped, CatalogMetaCache metaCache, 
int tableCacheMaxSize,
+            long tableCacheTtlSecond, Options catalogOptions, boolean 
cacheEnabled,
+            boolean hasEnclosingWeightLimit) {
+        return tryToCreate(wrapped, metaCache, tableCacheMaxSize, 
tableCacheTtlSecond,
+                catalogOptions, cacheEnabled, hasEnclosingWeightLimit,
+                catalog -> PrivilegedCatalog.tryToCreate(catalog, 
catalogOptions));
+    }
+
+    static Catalog tryToCreate(Catalog wrapped, CatalogMetaCache metaCache, 
int tableCacheMaxSize,
+            long tableCacheTtlSecond, Options catalogOptions, boolean 
cacheEnabled,
+            boolean hasEnclosingWeightLimit, Function<Catalog, Catalog> 
decorator) {
+        PaimonMetaCacheCatalog cached = null;
+        try {
+            cached = new PaimonMetaCacheCatalog(wrapped, metaCache, 
tableCacheMaxSize,
+                    tableCacheTtlSecond, catalogOptions, cacheEnabled, 
hasEnclosingWeightLimit,
+                    System::nanoTime, (name, key) -> { });
+            return decorator.apply(cached);
+        } catch (RuntimeException | Error throwable) {
+            if (cached != null) {
+                try {
+                    cached.unregisterCaches();
+                } catch (RuntimeException | Error rollbackFailure) {
+                    throwable.addSuppressed(rollbackFailure);
+                }
+            }
+            try {
+                wrapped.close();
+            } catch (Exception | Error closeFailure) {
+                throwable.addSuppressed(closeFailure);
+            }
+            throw throwable;
+        }
+    }
+
+    PaimonMetaCacheCatalog(Catalog wrapped, CatalogMetaCache metaCache, int 
tableCacheMaxSize,
+            long tableCacheTtlSecond, Options catalogOptions, boolean 
hasEnclosingWeightLimit,
+            LongSupplier nanoTime) {
+        this(wrapped, metaCache, tableCacheMaxSize, tableCacheTtlSecond, 
catalogOptions,
+                true, hasEnclosingWeightLimit, nanoTime, (name, key) -> { });
+    }
+
+    PaimonMetaCacheCatalog(Catalog wrapped, CatalogMetaCache metaCache, int 
tableCacheMaxSize,
+            long tableCacheTtlSecond, Options catalogOptions, boolean 
hasEnclosingWeightLimit,
+            LongSupplier nanoTime, BiConsumer<String, Object> 
cacheMissObserver) {
+        this(wrapped, metaCache, tableCacheMaxSize, tableCacheTtlSecond, 
catalogOptions,
+                true, hasEnclosingWeightLimit, nanoTime, cacheMissObserver);
+    }
+
+    private PaimonMetaCacheCatalog(Catalog wrapped, CatalogMetaCache 
metaCache, int tableCacheMaxSize,
+            long tableCacheTtlSecond, Options catalogOptions, boolean 
cacheEnabled,
+            boolean hasEnclosingWeightLimit, LongSupplier nanoTime,
+            BiConsumer<String, Object> cacheMissObserver) {
+        super(wrapped);
+        this.metaCache = metaCache;
+        this.nanoTime = nanoTime;
+        this.cacheMissObserver = cacheMissObserver;
+
+        Duration expireAfterAccess = cacheEnabled
+                ? catalogOptions.get(CatalogOptions.CACHE_EXPIRE_AFTER_ACCESS) 
: null;
+        Duration expireAfterWrite = cacheEnabled
+                ? catalogOptions.get(CatalogOptions.CACHE_EXPIRE_AFTER_WRITE) 
: null;
+        if (cacheEnabled) {
+            requirePositive(expireAfterAccess, 
CatalogOptions.CACHE_EXPIRE_AFTER_ACCESS.key());
+            requirePositive(expireAfterWrite, 
CatalogOptions.CACHE_EXPIRE_AFTER_WRITE.key());
+        }
+        long paimonAccessNanos = cacheEnabled ? 
saturatedNanos(expireAfterAccess) : Long.MAX_VALUE;
+        this.tableExpireAfterAccessNanos = cacheEnabled && tableCacheTtlSecond 
> 0
+                ? Math.min(paimonAccessNanos, 
saturatedNanos(Duration.ofSeconds(tableCacheTtlSecond)))
+                : paimonAccessNanos;
+        this.databaseExpireAfterAccessNanos = paimonAccessNanos;
+        this.expireAfterWriteNanos = cacheEnabled ? 
saturatedNanos(expireAfterWrite) : Long.MAX_VALUE;
+        this.attachSdkCaches = cacheEnabled && !hasEnclosingWeightLimit;
+        this.manifestCache = attachSdkCaches ? 
buildManifestCache(catalogOptions) : null;
+        this.snapshotMaxNumPerTable = attachSdkCaches
+                ? 
catalogOptions.get(CatalogOptions.CACHE_SNAPSHOT_MAX_NUM_PER_TABLE) : 0;
+
+        CacheSpec tableSpec = CacheSpec.of(cacheEnabled,
+                cacheEnabled && tableCacheTtlSecond > 0
+                        ? CacheSpec.CACHE_NO_TTL : 
CacheSpec.CACHE_TTL_DISABLE_CACHE,
+                tableCacheMaxSize);
+        MetaCache<Identifier, ExpiringValue<Table>> createdTableCache = 
metaCache.create(
+                MetaCacheDefinition
+                        .<Identifier, 
ExpiringValue<Table>>builder("paimon-table", tableSpec,
+                                id -> ScopePath.table(id.getDatabaseName(), 
id.getTableName()))
+                        .sizeEstimator((id, value) -> 
PaimonCacheSizeEstimator.estimateTable(
+                                id, value.value, TABLE_ENTRY_OVERHEAD_BYTES))
+                        .build());
+        try {
+            CacheSpec dbSpec = CacheSpec.of(cacheEnabled, cacheEnabled
+                    ? CacheSpec.CACHE_NO_TTL : 
CacheSpec.CACHE_TTL_DISABLE_CACHE, DATABASE_CACHE_CAPACITY);
+            this.databaseCache = metaCache.create(MetaCacheDefinition
+                    .<String, 
ExpiringValue<Database>>builder("paimon-database", dbSpec, ScopePath::database)
+                    .sizeEstimator(MetaCacheSizeEstimators.reflective())
+                    .build());
+        } catch (RuntimeException | Error throwable) {
+            try {
+                metaCache.remove(createdTableCache);
+            } catch (RuntimeException | Error rollbackFailure) {
+                throwable.addSuppressed(rollbackFailure);
+            }
+            throw throwable;
+        }
+        this.tableCache = createdTableCache;
+    }
+
+    @Override
+    public Table getTable(Identifier identifier) throws TableNotExistException 
{
+        if (identifier.isSystemTable()) {
+            Identifier origin = new Identifier(identifier.getDatabaseName(), 
identifier.getTableName(),
+                    identifier.getBranchName(), null);
+            Table originTable = getTable(origin);
+            if (!(originTable instanceof FileStoreTable)) {
+                return super.getTable(identifier);
+            }
+            Table systemTable = 
SystemTableLoader.load(identifier.getSystemTableName(),
+                    (FileStoreTable) originTable);
+            if (systemTable == null) {
+                throw new TableNotExistException(identifier);
+            }
+            return systemTable;
+        }
+
+        while (true) {
+            ExpiringValue<Table> cached = tableCache.getIfPresent(identifier);
+            if (cached == null) {
+                cacheMissObserver.accept("table", identifier);
+                try {
+                    cached = tableCache.get(identifier, ignored -> {
+                        try {
+                            Table loaded = 
attachPerTableCaches(super.getTable(identifier));
+                            return new ExpiringValue<>(loaded, 
nanoTime.getAsLong());
+                        } catch (TableNotExistException e) {
+                            throw new CatalogLoadException(e);
+                        }
+                    });
+                } catch (CatalogLoadException e) {
+                    throw (TableNotExistException) e.getCause();
+                }
+            }
+            if (cached.tryAccess(nanoTime.getAsLong(), 
tableExpireAfterAccessNanos,
+                    expireAfterWriteNanos)) {
+                return cached.value;
+            }
+            tableCache.compareAndSet(identifier, cached, null);
+        }
+    }
+
+    @Override
+    public Database getDatabase(String name) throws DatabaseNotExistException {
+        while (true) {
+            ExpiringValue<Database> cached = databaseCache.getIfPresent(name);
+            if (cached == null) {
+                cacheMissObserver.accept("database", name);
+                try {
+                    cached = databaseCache.get(name, ignored -> {
+                        try {
+                            return new 
ExpiringValue<>(super.getDatabase(name), nanoTime.getAsLong());
+                        } catch (DatabaseNotExistException e) {
+                            throw new CatalogLoadException(e);
+                        }
+                    });
+                } catch (CatalogLoadException e) {
+                    throw (DatabaseNotExistException) e.getCause();
+                }
+            }
+            if (cached.tryAccess(nanoTime.getAsLong(), 
databaseExpireAfterAccessNanos,
+                    expireAfterWriteNanos)) {
+                return cached.value;
+            }
+            databaseCache.compareAndSet(name, cached, null);
+        }
+    }
+
+    @Override
+    public void dropDatabase(String name, boolean ignoreIfNotExists, boolean 
cascade)
+            throws DatabaseNotExistException, DatabaseNotEmptyException {
+        try {
+            super.dropDatabase(name, ignoreIfNotExists, cascade);
+        } finally {
+            metaCache.invalidateDatabase(name);
+        }
+    }
+
+    @Override
+    public void alterDatabase(String name, List<PropertyChange> changes, 
boolean ignoreIfNotExists)
+            throws DatabaseNotExistException {
+        try {
+            super.alterDatabase(name, changes, ignoreIfNotExists);
+        } finally {
+            metaCache.invalidateDatabase(name);
+        }
+    }
+
+    @Override
+    public void dropTable(Identifier identifier, boolean ignoreIfNotExists)
+            throws TableNotExistException {
+        try {
+            super.dropTable(identifier, ignoreIfNotExists);
+        } finally {
+            invalidateTable(identifier);
+        }
+    }
+
+    @Override
+    public void renameTable(Identifier fromTable, Identifier toTable, boolean 
ignoreIfNotExists)
+            throws TableNotExistException, TableAlreadyExistException {
+        try {
+            super.renameTable(fromTable, toTable, ignoreIfNotExists);
+        } finally {
+            invalidateTable(fromTable);
+            invalidateTable(toTable);
+        }
+    }
+
+    @Override
+    public void alterTable(Identifier identifier, List<SchemaChange> changes, 
boolean ignoreIfNotExists)
+            throws TableNotExistException, ColumnAlreadyExistException, 
ColumnNotExistException {
+        try {
+            super.alterTable(identifier, changes, ignoreIfNotExists);
+        } finally {
+            invalidateTable(identifier);
+        }
+    }
+
+    @Override
+    public void invalidateTable(Identifier identifier) {
+        metaCache.invalidateTable(identifier.getDatabaseName(), 
identifier.getTableName());
+        super.invalidateTable(identifier);
+    }
+
+    @Override
+    public CatalogLoader catalogLoader() {
+        return wrapped.catalogLoader();
+    }
+
+    private void unregisterCaches() {
+        metaCache.remove(databaseCache);
+        metaCache.remove(tableCache);
+    }
+
+    private Table attachPerTableCaches(Table table) {
+        if (!attachSdkCaches || !(table instanceof FileStoreTable)) {
+            return table;
+        }
+        FileStoreTable storeTable = (FileStoreTable) table;
+        Duration expireAfterAccess = 
Duration.ofNanos(databaseExpireAfterAccessNanos);
+        Duration expireAfterWrite = Duration.ofNanos(expireAfterWriteNanos);
+        storeTable.setSnapshotCache(Caffeine.newBuilder()
+                .softValues()
+                .expireAfterAccess(expireAfterAccess)
+                .expireAfterWrite(expireAfterWrite)
+                .maximumSize(snapshotMaxNumPerTable)
+                .executor(Runnable::run)
+                .build());
+        storeTable.setStatsCache(Caffeine.newBuilder()
+                .softValues()
+                .expireAfterAccess(expireAfterAccess)
+                .expireAfterWrite(expireAfterWrite)
+                .maximumSize(5)
+                .executor(Runnable::run)
+                .build());
+        storeTable.setManifestCache(manifestCache);
+        return table;
+    }
+
+    private static SegmentsCache<Path> buildManifestCache(Options options) {
+        MemorySize manifestMaxMemory = 
options.get(CatalogOptions.CACHE_MANIFEST_SMALL_FILE_MEMORY);
+        long manifestCacheThreshold = options.get(
+                CatalogOptions.CACHE_MANIFEST_SMALL_FILE_THRESHOLD).getBytes();
+        Optional<MemorySize> maxMemory = 
options.getOptional(CatalogOptions.CACHE_MANIFEST_MAX_MEMORY);
+        if (maxMemory.isPresent() && 
maxMemory.get().compareTo(manifestMaxMemory) > 0) {
+            manifestMaxMemory = maxMemory.get();
+            manifestCacheThreshold = Long.MAX_VALUE;
+        }
+        return SegmentsCache.create(manifestMaxMemory, manifestCacheThreshold);
+    }
+
+    private static void requirePositive(Duration duration, String option) {
+        if (duration.isZero() || duration.isNegative()) {
+            throw new IllegalArgumentException("When '" + option
+                    + "' is set to negative or 0, the catalog cache should be 
disabled.");
+        }
+    }
+
+    private static long saturatedNanos(Duration duration) {
+        try {
+            return duration.toNanos();
+        } catch (ArithmeticException e) {
+            return Long.MAX_VALUE;
+        }
+    }
+
+    private static final class ExpiringValue<V> {
+        private final V value;
+        private final long createdNanos;
+        private final AtomicLong lastAccessNanos;
+
+        private ExpiringValue(V value, long createdNanos) {
+            this.value = value;
+            this.createdNanos = createdNanos;
+            this.lastAccessNanos = new AtomicLong(createdNanos);
+        }
+
+        private boolean tryAccess(long now, long expireAfterAccessNanos, long 
expireAfterWriteNanos) {
+            while (true) {
+                long lastAccess = lastAccessNanos.get();
+                if (now - createdNanos >= expireAfterWriteNanos
+                        || now - lastAccess >= expireAfterAccessNanos) {
+                    return false;
+                }
+                if (now <= lastAccess) {
+                    return true;
+                }
+                if (lastAccessNanos.compareAndSet(lastAccess, now)) {
+                    return true;
+                }
+            }
+        }
+    }
+
+    private static final class CatalogLoadException extends RuntimeException {
+        private CatalogLoadException(Exception cause) {
+            super(cause);
+        }
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java
index c1c13f67e54..525f4855b9f 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java
@@ -97,10 +97,17 @@ public class PaimonCatalogFactoryTest {
         Map<String, String> defaults = props(
                 "paimon.catalog.type", "filesystem", "warehouse", "/wh");
 
+        PaimonCatalogProperties defaultProperties = 
PaimonCatalogProperties.of(defaults);
+        
Assertions.assertTrue(PaimonCatalogFactory.isCatalogCacheEnabled(defaultProperties),
+                "Paimon caching remains enabled by default through the 
Doris-owned wrapper");
+
+        // The SDK wrapper itself is always disabled; governance changes only 
whether mutable SDK
+        // child caches can be attached to the Doris-governed table entry.
         try (PaimonConnector ungoverned = new PaimonConnector(defaults, new 
RecordingConnectorContext())) {
             Options options = ungoverned.buildCatalogOptions();
-            
Assertions.assertFalse(options.contains(CatalogOptions.CACHE_ENABLED),
-                    "without a global/catalog total, Doris must preserve the 
Paimon SDK default");
+            
Assertions.assertTrue(options.contains(CatalogOptions.CACHE_ENABLED));
+            Assertions.assertFalse(options.get(CatalogOptions.CACHE_ENABLED),
+                    "the Paimon SDK CachingCatalog must be disabled even 
without a Doris weight limit");
         }
 
         Map<String, String> catalogLimited = new HashMap<>(defaults);
@@ -109,25 +116,29 @@ public class PaimonCatalogFactoryTest {
             Options options = governed.buildCatalogOptions();
             
Assertions.assertTrue(options.contains(CatalogOptions.CACHE_ENABLED));
             Assertions.assertFalse(options.get(CatalogOptions.CACHE_ENABLED),
-                    "an enclosing Doris hard limit must not be bypassed by an 
unaccounted SDK cache");
+                    "the Paimon SDK CachingCatalog must stay disabled under an 
enclosing Doris hard limit");
         }
 
         Map<String, String> entryLimited = new HashMap<>(defaults);
         entryLimited.put("meta.cache.paimon.partition_view.max-weight", "1MB");
         try (PaimonConnector entryOnly = new PaimonConnector(entryLimited, new 
RecordingConnectorContext())) {
-            
Assertions.assertFalse(entryOnly.buildCatalogOptions().contains(CatalogOptions.CACHE_ENABLED),
-                    "an entry-only limit must preserve the Paimon SDK 
default");
+            
Assertions.assertTrue(entryOnly.buildCatalogOptions().contains(CatalogOptions.CACHE_ENABLED));
+            
Assertions.assertFalse(entryOnly.buildCatalogOptions().get(CatalogOptions.CACHE_ENABLED),
+                    "the Paimon SDK CachingCatalog must be disabled 
unconditionally");
         }
 
+        // The user flag controls whether the Doris wrapper is created, while 
the SDK flag remains off.
         PaimonCatalogProperties explicitTrue = 
PaimonCatalogProperties.of(props(
                 "paimon.catalog.type", "filesystem", "warehouse", "/wh", 
"paimon.cache-enabled", "true"));
-        
Assertions.assertTrue(PaimonCatalogFactory.buildCatalogOptions(explicitTrue, 
true)
-                .get(CatalogOptions.CACHE_ENABLED), "an explicit Paimon 
setting must win");
+        
Assertions.assertTrue(PaimonCatalogFactory.isCatalogCacheEnabled(explicitTrue));
+        
Assertions.assertFalse(PaimonCatalogFactory.buildCatalogOptions(explicitTrue)
+                .get(CatalogOptions.CACHE_ENABLED));
 
         PaimonCatalogProperties explicitFalse = 
PaimonCatalogProperties.of(props(
                 "paimon.catalog.type", "filesystem", "warehouse", "/wh", 
"paimon.cache-enabled", "false"));
-        
Assertions.assertFalse(PaimonCatalogFactory.buildCatalogOptions(explicitFalse, 
true)
-                .get(CatalogOptions.CACHE_ENABLED), "an explicit Paimon 
setting must win");
+        
Assertions.assertFalse(PaimonCatalogFactory.isCatalogCacheEnabled(explicitFalse));
+        
Assertions.assertFalse(PaimonCatalogFactory.buildCatalogOptions(explicitFalse)
+                .get(CatalogOptions.CACHE_ENABLED));
     }
 
     @Test
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogOptionsSnapshotTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogOptionsSnapshotTest.java
index 027b37e9e9e..9e4910002fa 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogOptionsSnapshotTest.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogOptionsSnapshotTest.java
@@ -85,7 +85,10 @@ public class PaimonCatalogOptionsSnapshotTest {
                 props("metastore", "filesystem",
                         "catalog.type", "filesystem",
                         "warehouse", "/wh",
-                        "read.batch-size", "4096"),
+                        "read.batch-size", "4096",
+                        // Doris owns the paimon Table cache 
(PaimonTableCache); the SDK CachingCatalog is
+                        // always disabled so frozen Tables cannot outlive a 
REFRESH (DORIS-29032).
+                        "cache-enabled", "false"),
                 withExcludedNamespaces(props(
                         "paimon.catalog.type", "filesystem",
                         "warehouse", "/wh",
@@ -99,7 +102,8 @@ public class PaimonCatalogOptionsSnapshotTest {
     public void defaultFlavorSnapshotIsFilesystem() {
         assertOptions(
                 props("metastore", "filesystem",
-                        "warehouse", "/wh"),
+                        "warehouse", "/wh",
+                        "cache-enabled", "false"),
                 props("warehouse", "/wh"));
     }
 
@@ -113,7 +117,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                         // Both are emitted unconditionally, at their legacy 
defaults when unset.
                         "client-pool-cache.eviction-interval-ms", "300000",
                         "location-in-properties", "false",
-                        "read.batch-size", "4096"),
+                        "read.batch-size", "4096",
+                        "cache-enabled", "false"),
                 withExcludedNamespaces(props(
                         "paimon.catalog.type", "hms",
                         "warehouse", "/wh",
@@ -132,7 +137,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                         "warehouse", "/wh",
                         "uri", "thrift://alias:9083",
                         "client-pool-cache.eviction-interval-ms", "60000",
-                        "location-in-properties", "true"),
+                        "location-in-properties", "true",
+                        "cache-enabled", "false"),
                 props("paimon.catalog.type", "hms",
                         "warehouse", "/wh",
                         "uri", "thrift://alias:9083",
@@ -157,7 +163,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                         "warehouse", "/wh",
                         "uri", "thrift://nn:9083",
                         "client-pool-cache.eviction-interval-ms", "300000",
-                        "location-in-properties", "false"),
+                        "location-in-properties", "false",
+                        "cache-enabled", "false"),
                 props("paimon.catalog.type", "hms",
                         "warehouse", " /wh ",
                         "hive.metastore.uris", " thrift://nn:9083 "));
@@ -181,7 +188,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                         "rest.uri", "http://rest:8080";,
                         "rest.token.provider", "dlf",
                         "rest.dlf.access-key-id", "ak",
-                        "read.batch-size", "4096"),
+                        "read.batch-size", "4096",
+                        "cache-enabled", "false"),
                 withExcludedNamespaces(props(
                         "paimon.catalog.type", "rest",
                         "warehouse", "/wh",
@@ -198,7 +206,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                 props("metastore", "rest",
                         "catalog.type", "rest",
                         "warehouse", "/wh",
-                        "uri", "http://rest:8080";),
+                        "uri", "http://rest:8080";,
+                        "cache-enabled", "false"),
                 props("paimon.catalog.type", "rest",
                         "warehouse", "/wh",
                         "uri", "http://rest:8080";));
@@ -220,7 +229,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                         "catalog.type", "rest",
                         "warehouse", "/wh",
                         "uri", "http://rest:8080";,
-                        "rest.uri", " http://rest:8080 "),
+                        "rest.uri", " http://rest:8080 ",
+                        "cache-enabled", "false"),
                 props("paimon.catalog.type", "rest",
                         "warehouse", " /wh ",
                         "paimon.rest.uri", " http://rest:8080 "));
@@ -244,7 +254,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                         "jdbc.driver_url", "mysql.jar",
                         "jdbc.driver_class", "com.mysql.cj.jdbc.Driver",
                         "jdbc.foo", "bar",
-                        "read.batch-size", "4096"),
+                        "read.batch-size", "4096",
+                        "cache-enabled", "false"),
                 withExcludedNamespaces(props(
                         "paimon.catalog.type", "jdbc",
                         "warehouse", "/wh",
@@ -265,7 +276,8 @@ public class PaimonCatalogOptionsSnapshotTest {
                         "catalog.type", "jdbc",
                         "warehouse", "/wh",
                         "uri", "jdbc:mysql://db:3306/meta",
-                        "jdbc.uri", "jdbc:mysql://db:3306/meta"),
+                        "jdbc.uri", "jdbc:mysql://db:3306/meta",
+                        "cache-enabled", "false"),
                 props("paimon.catalog.type", "jdbc",
                         "warehouse", "/wh",
                         "paimon.jdbc.uri", "jdbc:mysql://db:3306/meta"));
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonMetaCacheCatalogTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonMetaCacheCatalogTest.java
new file mode 100644
index 00000000000..d7c4a8ae7e3
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonMetaCacheCatalogTest.java
@@ -0,0 +1,679 @@
+// 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.doris.connector.paimon;
+
+import org.apache.doris.connector.cache.CacheSpec;
+import org.apache.doris.connector.cache.CatalogMetaCache;
+import org.apache.doris.connector.cache.MetaCache;
+import org.apache.doris.connector.cache.MetaCacheBudgetManager;
+import org.apache.doris.connector.cache.MetaCacheDefinition;
+import org.apache.doris.connector.cache.ScopePath;
+
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.catalog.Database;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.CatalogOptions;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.privilege.PrivilegeChecker;
+import org.apache.paimon.privilege.PrivilegeManager;
+import org.apache.paimon.privilege.PrivilegedCatalog;
+import org.apache.paimon.privilege.PrivilegedFileStoreTable;
+import org.apache.paimon.rest.RESTCatalog;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.system.AllTableOptionsTable;
+import org.apache.paimon.types.DataTypes;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.lang.reflect.Proxy;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+class PaimonMetaCacheCatalogTest {
+    private static final Identifier TABLE = Identifier.create("db", "t");
+
+    @Test
+    void disabledCacheOnlyKeepsTheInvalidationDecorator() throws Exception {
+        RecordingCatalog recording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            MetaCache<Identifier, String> sibling = 
owner.create(MetaCacheDefinition
+                    .<Identifier, String>builder("paimon-sibling", 
CacheSpec.of(true, -1, 10),
+                            id -> ScopePath.table(id.getDatabaseName(), 
id.getTableName()))
+                    .build());
+            Catalog result = 
PaimonMetaCacheCatalog.tryToCreate(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ZERO, Duration.ZERO),
+                    false, false);
+
+            Assertions.assertNotSame(recording.catalog(), result);
+            Assertions.assertNotSame(result.getTable(TABLE), 
result.getTable(TABLE));
+            Assertions.assertEquals(2, recording.tableLoads.get());
+            sibling.put(TABLE, "stale");
+            result.dropTable(TABLE, true);
+            Assertions.assertNull(sibling.getIfPresent(TABLE));
+        }
+    }
+
+    @Test
+    void failedDecorationRollsBackCacheRegistrationsAndClosesCatalog() {
+        RecordingCatalog failed = new RecordingCatalog();
+        RecordingCatalog retry = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            Assertions.assertThrows(IllegalStateException.class,
+                    () -> PaimonMetaCacheCatalog.tryToCreate(failed.catalog(), 
owner,
+                            100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                            true, false, ignored -> {
+                                throw new IllegalStateException("privilege 
metadata is temporarily unavailable");
+                            }));
+
+            Assertions.assertTrue(owner.entries().isEmpty());
+            Assertions.assertEquals(1, failed.closeCalls.get());
+            Assertions.assertDoesNotThrow(() -> 
PaimonMetaCacheCatalog.tryToCreate(retry.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    true, false, catalog -> catalog));
+            Assertions.assertEquals(2, owner.entries().size());
+        }
+    }
+
+    @Test
+    void disabledCacheDoesNotParseUnusedSettings() {
+        Options options = new Options();
+        options.set(CatalogOptions.CACHE_EXPIRE_AFTER_ACCESS.key(), 
"invalid-duration");
+        options.set(CatalogOptions.CACHE_EXPIRE_AFTER_WRITE.key(), 
"invalid-duration");
+        options.set(CatalogOptions.CACHE_SNAPSHOT_MAX_NUM_PER_TABLE.key(), 
"invalid-integer");
+        options.set(CatalogOptions.CACHE_MANIFEST_SMALL_FILE_MEMORY.key(), 
"invalid-memory");
+        RecordingCatalog recording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            Assertions.assertDoesNotThrow(() -> 
PaimonMetaCacheCatalog.tryToCreate(
+                    recording.catalog(), owner, 100, 100, options,
+                    false, false, catalog -> catalog));
+        }
+    }
+
+    @Test
+    void enclosingWeightLimitDoesNotParseUnusedSdkCacheSettings() {
+        Options options = cacheOptions(Duration.ofDays(1), Duration.ofDays(1));
+        options.set(CatalogOptions.CACHE_SNAPSHOT_MAX_NUM_PER_TABLE.key(), 
"invalid-integer");
+        options.set(CatalogOptions.CACHE_MANIFEST_SMALL_FILE_MEMORY.key(), 
"invalid-memory");
+        RecordingCatalog recording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            Assertions.assertDoesNotThrow(() -> new PaimonMetaCacheCatalog(
+                    recording.catalog(), owner, 100, 100, options, true, 
System::nanoTime));
+        }
+    }
+
+    @Test
+    void tableAndDatabaseHonorAccessAndWriteExpiry() throws Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog accessRecording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(accessRecording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofSeconds(5), 
Duration.ofSeconds(100)),
+                    false, clock::get);
+
+            Assertions.assertSame(catalog.getTable(TABLE), 
catalog.getTable(TABLE));
+            Assertions.assertSame(catalog.getDatabase("db"), 
catalog.getDatabase("db"));
+            clock.set(Duration.ofSeconds(6).toNanos());
+            catalog.getTable(TABLE);
+            catalog.getDatabase("db");
+
+            Assertions.assertEquals(2, accessRecording.tableLoads.get());
+            Assertions.assertEquals(2, accessRecording.databaseLoads.get());
+        }
+
+        clock.set(0);
+        RecordingCatalog writeRecording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(writeRecording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofSeconds(100), 
Duration.ofSeconds(10)),
+                    false, clock::get);
+
+            Table first = catalog.getTable(TABLE);
+            clock.set(Duration.ofSeconds(5).toNanos());
+            Assertions.assertSame(first, catalog.getTable(TABLE));
+            clock.set(Duration.ofSeconds(11).toNanos());
+            Assertions.assertNotSame(first, catalog.getTable(TABLE));
+            Assertions.assertEquals(2, writeRecording.tableLoads.get());
+        }
+    }
+
+    @Test
+    void 
tableMissRaceDoesNotReturnAValueThatExpiredBeforePublicationWasObserved() 
throws Exception {
+        AtomicLong clock = new AtomicLong();
+        AtomicInteger misses = new AtomicInteger();
+        CountDownLatch firstMiss = new CountDownLatch(1);
+        CountDownLatch releaseFirstMiss = new CountDownLatch(1);
+        RecordingCatalog recording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofSeconds(1), 
Duration.ofDays(1)),
+                    false, clock::get, (cache, key) -> {
+                        if (cache.equals("table") && misses.incrementAndGet() 
== 1) {
+                            firstMiss.countDown();
+                            await(releaseFirstMiss);
+                        }
+                    });
+            ExecutorService executor = Executors.newSingleThreadExecutor();
+            try {
+                Future<Table> racing = executor.submit(() -> 
catalog.getTable(TABLE));
+                Assertions.assertTrue(firstMiss.await(10, TimeUnit.SECONDS));
+                Table expired = catalog.getTable(TABLE);
+                clock.set(Duration.ofSeconds(2).toNanos());
+                releaseFirstMiss.countDown();
+
+                Assertions.assertNotSame(expired, racing.get(10, 
TimeUnit.SECONDS));
+                Assertions.assertEquals(2, recording.tableLoads.get());
+            } finally {
+                releaseFirstMiss.countDown();
+                executor.shutdownNow();
+            }
+        }
+    }
+
+    @Test
+    void 
databaseMissRaceDoesNotReturnAValueThatExpiredBeforePublicationWasObserved() 
throws Exception {
+        AtomicLong clock = new AtomicLong();
+        AtomicInteger misses = new AtomicInteger();
+        CountDownLatch firstMiss = new CountDownLatch(1);
+        CountDownLatch releaseFirstMiss = new CountDownLatch(1);
+        RecordingCatalog recording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofSeconds(1), 
Duration.ofDays(1)),
+                    false, clock::get, (cache, key) -> {
+                        if (cache.equals("database") && 
misses.incrementAndGet() == 1) {
+                            firstMiss.countDown();
+                            await(releaseFirstMiss);
+                        }
+                    });
+            ExecutorService executor = Executors.newSingleThreadExecutor();
+            try {
+                Future<Database> racing = executor.submit(() -> 
catalog.getDatabase("db"));
+                Assertions.assertTrue(firstMiss.await(10, TimeUnit.SECONDS));
+                Database expired = catalog.getDatabase("db");
+                clock.set(Duration.ofSeconds(2).toNanos());
+                releaseFirstMiss.countDown();
+
+                Assertions.assertNotSame(expired, racing.get(10, 
TimeUnit.SECONDS));
+                Assertions.assertEquals(2, recording.databaseLoads.get());
+            } finally {
+                releaseFirstMiss.countDown();
+                executor.shutdownNow();
+            }
+        }
+    }
+
+    @Test
+    void acceptedLargeDurationsSaturateInsteadOfFailingCatalogCreation() 
throws Exception {
+        RecordingCatalog recording = new RecordingCatalog();
+        Options options = new Options();
+        options.set(CatalogOptions.CACHE_EXPIRE_AFTER_ACCESS.key(), 
"9223372037s");
+        options.set(CatalogOptions.CACHE_EXPIRE_AFTER_WRITE.key(), 
"9223372037s");
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, Long.MAX_VALUE, options, false, System::nanoTime);
+
+            Assertions.assertSame(catalog.getTable(TABLE), 
catalog.getTable(TABLE));
+            Assertions.assertEquals(1, recording.tableLoads.get());
+        }
+    }
+
+    @Test
+    void lastAccessTimeNeverMovesBackward() throws Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog recording = new RecordingCatalog();
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofSeconds(10), 
Duration.ofSeconds(100)),
+                    false, clock::get);
+
+            Table first = catalog.getTable(TABLE);
+            clock.set(Duration.ofSeconds(5).toNanos());
+            catalog.getTable(TABLE);
+            clock.set(Duration.ofSeconds(1).toNanos());
+            catalog.getTable(TABLE);
+            clock.set(Duration.ofSeconds(12).toNanos());
+
+            Assertions.assertSame(first, catalog.getTable(TABLE));
+            Assertions.assertEquals(1, recording.tableLoads.get());
+        }
+    }
+
+    @Test
+    void baseTableInvalidationEvictsEveryBranchVariant() throws Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog recording = new RecordingCatalog();
+        Identifier branch = new Identifier("db", "t", "dev", null);
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    false, clock::get);
+
+            Table mainBefore = catalog.getTable(TABLE);
+            Table branchBefore = catalog.getTable(branch);
+            owner.invalidateTable("db", "t");
+
+            Assertions.assertNotSame(mainBefore, catalog.getTable(TABLE));
+            Assertions.assertNotSame(branchBefore, catalog.getTable(branch));
+            Assertions.assertEquals(4, recording.tableLoads.get());
+        }
+    }
+
+    @Test
+    void systemTableIsRebuiltFromTheCachedOriginTable() throws Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog recording = new RecordingCatalog();
+        recording.fileStoreTables = true;
+        Identifier systemTable = Identifier.create("db", "t$snapshots");
+        Assertions.assertTrue(systemTable.isSystemTable());
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    true, clock::get);
+
+            Assertions.assertNotNull(catalog.getTable(systemTable));
+            Assertions.assertEquals(TABLE, recording.lastLoadedTable.get());
+            Assertions.assertEquals(1, recording.tableLoads.get());
+        }
+    }
+
+    @Test
+    void nonFileStoreSystemTableIsDelegatedToTheWrappedCatalog() throws 
Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog recording = new RecordingCatalog();
+        Identifier systemTable = Identifier.create("db", "t$snapshots");
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    false, clock::get);
+
+            Assertions.assertNotNull(catalog.getTable(systemTable));
+            Assertions.assertEquals(systemTable, 
recording.lastLoadedTable.get());
+            Assertions.assertEquals(2, recording.tableLoads.get());
+        }
+    }
+
+    @Test
+    void eachSuccessfulDropEvictsBeforeALaterDropFails() throws Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog recording = new RecordingCatalog();
+        Identifier first = Identifier.create("db", "first");
+        Identifier second = Identifier.create("db", "second");
+        recording.failDrop.set(second);
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    false, clock::get);
+
+            Table staleFirst = catalog.getTable(first);
+            catalog.getTable(second);
+            catalog.dropTable(first, true);
+            Assertions.assertThrows(Catalog.TableNotExistException.class,
+                    () -> catalog.dropTable(second, true));
+
+            Assertions.assertNotSame(staleFirst, catalog.getTable(first));
+        }
+    }
+
+    @Test
+    void mutationEvictsWhenTheDelegateThrowsAfterTheRemoteChange() throws 
Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog recording = new RecordingCatalog();
+        recording.failAfterDrop.set(TABLE);
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    false, clock::get);
+
+            Table stale = catalog.getTable(TABLE);
+            Assertions.assertThrows(IllegalStateException.class, () -> 
catalog.dropTable(TABLE, true));
+
+            Assertions.assertNotSame(stale, catalog.getTable(TABLE));
+            Assertions.assertEquals(2, recording.tableLoads.get());
+        }
+    }
+
+    @Test
+    void enclosingWeightLimitDoesNotAttachMutableSdkCaches() throws Exception {
+        AtomicLong clock = new AtomicLong();
+        RecordingCatalog governedRecording = new RecordingCatalog();
+        governedRecording.fileStoreTables = true;
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog governed = new 
PaimonMetaCacheCatalog(governedRecording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    true, clock::get);
+            governed.getTable(TABLE);
+            Assertions.assertEquals(0, 
governedRecording.sdkCacheAttachments.get());
+        }
+
+        RecordingCatalog ungovernedRecording = new RecordingCatalog();
+        ungovernedRecording.fileStoreTables = true;
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog ungoverned = new 
PaimonMetaCacheCatalog(ungovernedRecording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    false, clock::get);
+            ungoverned.getTable(TABLE);
+            Assertions.assertEquals(3, 
ungovernedRecording.sdkCacheAttachments.get());
+        }
+    }
+
+    @Test
+    void realFileStoreTableIsAdmittedByTheWeightGovernedCache(@TempDir 
java.nio.file.Path warehouse)
+            throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        org.apache.paimon.fs.Path tablePath = new org.apache.paimon.fs.Path(
+                warehouse.resolve("weighted-table").toUri());
+        Schema schema = Schema.newBuilder()
+                .column("id", DataTypes.INT())
+                .column("payload", DataTypes.STRING())
+                .option("file.format", "parquet")
+                .build();
+        new SchemaManager(fileIO, tablePath).createTable(schema);
+
+        RecordingCatalog recording = new RecordingCatalog();
+        recording.tableSupplier = () -> FileStoreTableFactory.create(fileIO, 
tablePath);
+        MetaCacheBudgetManager budgetManager = new 
MetaCacheBudgetManager(OptionalLong.of(1024L * 1024L));
+        try (CatalogMetaCache owner = new CatalogMetaCache(
+                budgetManager, 67996L, "paimon", Collections.emptyMap())) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    true, System::nanoTime);
+
+            Table first = catalog.getTable(TABLE);
+            Assertions.assertSame(first, catalog.getTable(TABLE));
+            Assertions.assertEquals(1, recording.tableLoads.get());
+            Assertions.assertTrue(budgetManager.getGlobalUsedWeight() > 0L);
+        }
+        Assertions.assertEquals(0L, budgetManager.getGlobalUsedWeight());
+    }
+
+    @Test
+    void allTableOptionsIsNotAdmittedWithoutACompleteRetainedSizeEstimate() 
throws Exception {
+        Map<Identifier, Map<String, String>> allOptions = new HashMap<>();
+        for (int i = 0; i < 100; i++) {
+            allOptions.put(Identifier.create("db", "table_" + i),
+                    Collections.singletonMap("large-option", "x".repeat(100)));
+        }
+        RecordingCatalog recording = new RecordingCatalog();
+        recording.tableSupplier = () -> new AllTableOptionsTable(allOptions);
+        MetaCacheBudgetManager budgetManager = new 
MetaCacheBudgetManager(OptionalLong.of(512L));
+        try (CatalogMetaCache owner = new CatalogMetaCache(
+                budgetManager, 67996L, "paimon", Collections.emptyMap())) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    true, System::nanoTime);
+
+            catalog.getTable(Identifier.create("sys", 
AllTableOptionsTable.ALL_TABLE_OPTIONS));
+            catalog.getTable(Identifier.create("sys", 
AllTableOptionsTable.ALL_TABLE_OPTIONS));
+            Assertions.assertEquals(2, recording.tableLoads.get());
+            Assertions.assertEquals(0L, budgetManager.getGlobalUsedWeight());
+        }
+    }
+
+    @Test
+    void fallbackBranchesAreIncludedInWeightGovernedAdmission(@TempDir 
java.nio.file.Path warehouse)
+            throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        FileStoreTable main = createFileStoreTable(fileIO, 
warehouse.resolve("main"), "main_payload");
+        FileStoreTable fallback = createFileStoreTable(fileIO, 
warehouse.resolve("fallback"), "fallback_payload");
+        FileStoreTable decorated = new FallbackReadFileStoreTable(main, 
fallback);
+        long mainWeight = PaimonCacheSizeEstimator.estimateTable(
+                TABLE, main, 
PaimonMetaCacheCatalog.TABLE_ENTRY_OVERHEAD_BYTES).getBytes();
+        long decoratedWeight = PaimonCacheSizeEstimator.estimateTable(
+                TABLE, decorated, 
PaimonMetaCacheCatalog.TABLE_ENTRY_OVERHEAD_BYTES).getBytes();
+        long sharedBranchWeight = PaimonCacheSizeEstimator.estimateTable(
+                TABLE, new FallbackReadFileStoreTable(main, main),
+                PaimonMetaCacheCatalog.TABLE_ENTRY_OVERHEAD_BYTES).getBytes();
+
+        Assertions.assertTrue(decoratedWeight > mainWeight);
+        Assertions.assertTrue(decoratedWeight > sharedBranchWeight,
+                "the same branch object must be counted once by identity");
+        Assertions.assertFalse(PaimonCacheSizeEstimator.estimateTable(TABLE,
+                PrivilegedFileStoreTable.wrap(decorated, 
privilegeChecker(true), TABLE),
+                
PaimonMetaCacheCatalog.TABLE_ENTRY_OVERHEAD_BYTES).isComplete(),
+                "an authorization snapshot must never be admitted to the raw 
metadata cache");
+
+        RecordingCatalog recording = new RecordingCatalog();
+        recording.tableSupplier = () -> new FallbackReadFileStoreTable(main, 
fallback);
+        MetaCacheBudgetManager budgetManager = new MetaCacheBudgetManager(
+                OptionalLong.of(mainWeight));
+        try (CatalogMetaCache owner = new CatalogMetaCache(
+                budgetManager, 67996L, "paimon", Collections.emptyMap())) {
+            PaimonMetaCacheCatalog catalog = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    true, System::nanoTime);
+
+            Assertions.assertNotSame(catalog.getTable(TABLE), 
catalog.getTable(TABLE));
+            Assertions.assertEquals(2, recording.tableLoads.get());
+            Assertions.assertEquals(0L, budgetManager.getGlobalUsedWeight());
+        }
+    }
+
+    @Test
+    void privilegeCheckerIsRefreshedOutsideTheRawTableCache() throws Exception 
{
+        AtomicReference<Boolean> canSelect = new AtomicReference<>(true);
+        RecordingCatalog recording = new RecordingCatalog();
+        recording.fileStoreTables = true;
+        PrivilegeManager privilegeManager = (PrivilegeManager) 
Proxy.newProxyInstance(
+                PrivilegeManager.class.getClassLoader(), new Class<?>[] 
{PrivilegeManager.class},
+                (proxy, method, args) -> {
+                    if (method.getName().equals("getPrivilegeChecker")) {
+                        boolean snapshot = canSelect.get();
+                        return privilegeChecker(snapshot);
+                    }
+                    return defaultValue(method.getReturnType());
+                });
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog cached = new 
PaimonMetaCacheCatalog(recording.catalog(), owner,
+                    100, 100, cacheOptions(Duration.ofDays(1), 
Duration.ofDays(1)),
+                    false, System::nanoTime);
+            Catalog privileged = new PrivilegedCatalog(cached, () -> 
privilegeManager);
+
+            FileStoreTable beforeRevoke = (FileStoreTable) 
privileged.getTable(TABLE);
+            Assertions.assertDoesNotThrow(beforeRevoke::newScan);
+            canSelect.set(false);
+            FileStoreTable afterRevoke = (FileStoreTable) 
privileged.getTable(TABLE);
+
+            Assertions.assertNotSame(beforeRevoke, afterRevoke);
+            Assertions.assertThrows(IllegalStateException.class, 
afterRevoke::newScan);
+            Assertions.assertEquals(1, recording.tableLoads.get(),
+                    "revocation must refresh authorization without reloading 
raw metadata");
+        }
+    }
+
+    @Test
+    void restDispatchSeesThroughTheMetaCacheWrapper() {
+        Options options = cacheOptions(Duration.ofDays(1), Duration.ofDays(1));
+        options.set("uri", "http://localhost:1";);
+        options.set("prefix", "test-prefix");
+        options.set("token.provider", "bear");
+        options.set("token", "test-token");
+        RESTCatalog rest = new RESTCatalog(CatalogContext.create(options), 
false);
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            PaimonMetaCacheCatalog wrapped = new PaimonMetaCacheCatalog(rest, 
owner,
+                    100, 100, options, true, System::nanoTime);
+
+            Assertions.assertSame(rest,
+                    
PaimonCatalogOps.CatalogBackedPaimonCatalogOps.restCatalog(wrapped));
+        }
+    }
+
+    private static Options cacheOptions(Duration access, Duration write) {
+        Options options = new Options();
+        options.set(CatalogOptions.CACHE_EXPIRE_AFTER_ACCESS, access);
+        options.set(CatalogOptions.CACHE_EXPIRE_AFTER_WRITE, write);
+        return options;
+    }
+
+    private static FileStoreTable createFileStoreTable(
+            LocalFileIO fileIO, java.nio.file.Path path, String payloadColumn) 
throws Exception {
+        org.apache.paimon.fs.Path tablePath = new 
org.apache.paimon.fs.Path(path.toUri());
+        Schema schema = Schema.newBuilder()
+                .column("id", DataTypes.INT())
+                .column(payloadColumn, DataTypes.STRING())
+                .option("file.format", "parquet")
+                .build();
+        new SchemaManager(fileIO, tablePath).createTable(schema);
+        return FileStoreTableFactory.create(fileIO, tablePath);
+    }
+
+    private static PrivilegeChecker privilegeChecker(boolean canSelect) {
+        return (PrivilegeChecker) Proxy.newProxyInstance(
+                PrivilegeChecker.class.getClassLoader(), new Class<?>[] 
{PrivilegeChecker.class},
+                (proxy, method, args) -> {
+                    if (method.getName().equals("assertCanSelect") && 
!canSelect) {
+                        throw new IllegalStateException("SELECT privilege was 
revoked");
+                    }
+                    return null;
+                });
+    }
+
+    private static Object defaultValue(Class<?> returnType) {
+        if (!returnType.isPrimitive()) {
+            if (returnType == Optional.class) {
+                return Optional.empty();
+            }
+            if (returnType == Map.class) {
+                return Collections.emptyMap();
+            }
+            return null;
+        }
+        if (returnType == boolean.class) {
+            return false;
+        }
+        if (returnType == char.class) {
+            return '\0';
+        }
+        if (returnType == byte.class) {
+            return (byte) 0;
+        }
+        if (returnType == short.class) {
+            return (short) 0;
+        }
+        if (returnType == int.class) {
+            return 0;
+        }
+        if (returnType == long.class) {
+            return 0L;
+        }
+        if (returnType == float.class) {
+            return 0F;
+        }
+        return 0D;
+    }
+
+    private static void await(CountDownLatch latch) {
+        try {
+            if (!latch.await(10, TimeUnit.SECONDS)) {
+                throw new IllegalStateException("timed out waiting for cache 
race");
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("interrupted while waiting for 
cache race", e);
+        }
+    }
+
+    private static final class RecordingCatalog {
+        private final AtomicInteger tableLoads = new AtomicInteger();
+        private final AtomicInteger databaseLoads = new AtomicInteger();
+        private final AtomicInteger sdkCacheAttachments = new AtomicInteger();
+        private final AtomicInteger closeCalls = new AtomicInteger();
+        private final AtomicReference<Identifier> lastLoadedTable = new 
AtomicReference<>();
+        private final AtomicReference<Identifier> failDrop = new 
AtomicReference<>();
+        private final AtomicReference<Identifier> failAfterDrop = new 
AtomicReference<>();
+        private final Catalog catalog;
+        private boolean fileStoreTables;
+        private Supplier<Table> tableSupplier = this::newTable;
+
+        private RecordingCatalog() {
+            AtomicReference<Catalog> self = new AtomicReference<>();
+            this.catalog = (Catalog) 
Proxy.newProxyInstance(Catalog.class.getClassLoader(),
+                    new Class<?>[] {Catalog.class}, (proxy, method, args) -> {
+                        switch (method.getName()) {
+                            case "getTable":
+                                Identifier identifier = (Identifier) args[0];
+                                lastLoadedTable.set(identifier);
+                                tableLoads.incrementAndGet();
+                                return tableSupplier.get();
+                            case "getDatabase":
+                                databaseLoads.incrementAndGet();
+                                return Database.of((String) args[0]);
+                            case "dropTable":
+                                if (args[0].equals(failDrop.get())) {
+                                    throw new 
Catalog.TableNotExistException((Identifier) args[0]);
+                                }
+                                if (args[0].equals(failAfterDrop.get())) {
+                                    throw new 
IllegalStateException("post-mutation cleanup failed");
+                                }
+                                return null;
+                            case "catalogLoader":
+                                return 
(org.apache.paimon.catalog.CatalogLoader) self::get;
+                            case "close":
+                                closeCalls.incrementAndGet();
+                                return null;
+                            case "options":
+                                return Collections.emptyMap();
+                            case "toString":
+                                return "RecordingCatalog";
+                            default:
+                                return defaultValue(method.getReturnType());
+                        }
+                    });
+            self.set(catalog);
+        }
+
+        private Catalog catalog() {
+            return catalog;
+        }
+
+        private Table newTable() {
+            Class<?> tableType = fileStoreTables ? FileStoreTable.class : 
Table.class;
+            return (Table) Proxy.newProxyInstance(tableType.getClassLoader(), 
new Class<?>[] {tableType},
+                    (proxy, method, args) -> {
+                        if (method.getName().startsWith("set") && 
method.getName().endsWith("Cache")) {
+                            sdkCacheAttachments.incrementAndGet();
+                            return null;
+                        }
+                        if (method.getName().equals("toString")) {
+                            return "RecordingTable";
+                        }
+                        return defaultValue(method.getReturnType());
+                    });
+        }
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonRestCatalogPartitionsTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonRestCatalogPartitionsTest.java
index cb035de0b5b..d94692f6e9c 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonRestCatalogPartitionsTest.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonRestCatalogPartitionsTest.java
@@ -17,8 +17,10 @@
 
 package org.apache.doris.connector.paimon;
 
+import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.partition.Partition;
+import org.apache.paimon.rest.exceptions.ForbiddenException;
 import org.apache.paimon.rest.exceptions.NotImplementedException;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -59,4 +61,22 @@ class PaimonRestCatalogPartitionsTest {
 
         Assertions.assertSame(manifestPartitions, result);
     }
+
+    @Test
+    void forbiddenEndpointDoesNotFallBackToFilesystem() {
+        AtomicBoolean fallbackCalled = new AtomicBoolean();
+
+        Assertions.assertThrows(Catalog.TableNoPermissionException.class,
+                () -> PaimonRestCatalogPartitions.listPartitions(
+                        identifier -> {
+                            throw new ForbiddenException("forbidden");
+                        },
+                        TABLE,
+                        () -> {
+                            fallbackCalled.set(true);
+                            return Collections.emptyList();
+                        }));
+
+        Assertions.assertFalse(fallbackCalled.get());
+    }
 }


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

Reply via email to