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

morningman 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 a34b2c6068a [fix](fe) Keep Iceberg and Paimon metadata caches within 
their weight limits (#68842)
a34b2c6068a is described below

commit a34b2c6068ac91f232d73e5379dc0a37bbcd159b
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Oct 12 14:28:22 2026 +0800

    [fix](fe) Keep Iceberg and Paimon metadata caches within their weight 
limits (#68842)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #66717, #67726
    
    Problem Summary:
    
    **In short.** This ports four fixes from branch-4.1 #66717 (memory
    governance for external metadata caches) that master's re-implementation
    (#67726) does not have. Under a metadata cache weight limit, master
    still lets the Iceberg SDK keep its own unbounded manifest cache,
    fetches the whole snapshot history of REST `snapshot-loading-mode=refs`
    tables on every table cache miss, and under-weighs cached Paimon tables
    by 2-5x because they keep growing after admission. Catalogs without a
    weight limit are not affected.
    
    **Background.** #66717 bounds the estimated retained memory of external
    metadata caches with the FE-wide `external_meta_cache_max_weight`, the
    catalog property `meta.cache.max-weight`, and per-entry
    `meta.cache.<engine>.<entry>.max-weight`. A value is weighed once, when
    it is admitted; if it does not fit, it is returned to the query but not
    cached. master re-implemented this in #67726 on the connector cache
    framework, and #68839 ports the 4.x tests. Comparing the two
    implementations found the gaps below; branch-4.1 handles all of them.
    
    **The problem, and what it cost.**
    
    - **Iceberg SDK manifest cache outside the limit.**
    `IcebergCatalogFactory` derives `io.manifest.cache-enabled=true`
    whenever `meta.cache.iceberg.manifest.*` is enabled. That SDK cache
    keeps up to `io.manifest.cache.max-total-bytes` (100 MB by default) of
    manifest content per FileIO, outside every Doris limit, and Glue / S3
    Tables catalogs have one FileIO per table. A user who sets a limit gets
    a second, unbounded manifest cache next to the bounded one.
    - **Remote IO while weighing REST refs-mode tables.** A weight-limited
    `IcebergTableCache` serializes the loaded metadata
    (`TableMetadataParser.toJson`) and weighs it. Both call
    `TableMetadata.snapshots()`, which in refs mode runs the catalog's
    supplier and fetches every snapshot. So each table cache miss pays one
    extra full-history REST load, exactly the cost refs mode exists to
    avoid, and the cached value then holds the whole history.
    - **Paimon tables grow after admission.** `paimon-table` is weighed from
    the schema, location and catalog environment. A cached `FileStoreTable`
    then grows: the first `latestSnapshot()` or scan builds its transient
    store (`RowType` copies of the schema, a copy of the options, and for a
    partial-update table a merge function factory with sequence-group and
    aggregator maps for every field), and field lookups build the four lazy
    index maps of every `RowType`. Measured on Paimon 1.4.2, the grown graph
    is 2-5x the admitted weight, so the heap the cache really holds can
    exceed `MAX_WEIGHT` by that factor.
    - **Paimon tables can own their FileIO, which is not charged.** A REST
    catalog gives each table its own `RESTTokenFileIO` with data tokens (the
    vended token, about 2 KB, arrives on first data access) or
    `ResolvingFileIO` without them (`RESTCatalog.fileIOFromOptions`), and a
    Hive catalog resolves a FileIO per table location
    (`HiveCatalog.fileIO(Path)`). Their tokens and backend maps appear after
    admission.
    
    **How this PR fixes it.**
    
    - `IcebergConnector#hasMetaCacheWeightLimit` decides whether any limit
    applies: the FE-global limit, the catalog limit, or the `max-weight` of
    one of the entries the connector reads (`table`, `partition`,
    `manifest`, `partition_view`, now one constant shared with the DDL
    validation). The three catalog option builders take that flag, and
    `appendManifestCacheProperties` does not derive
    `io.manifest.cache-enabled` under a limit. An explicit user value still
    wins, as in branch-4.1.
    - `IcebergCacheSizeEstimator.areSnapshotsLoaded` reads
    `TableMetadata.snapshotsLoaded` (Iceberg exposes no accessor for it). A
    weight-limited `TableOwner` checks it first and, if the list is still
    behind its supplier, declines retention with
    `incomplete_estimate:iceberg_snapshots_not_loaded` instead of
    serializing. The statement keeps the live table, whose supplier still
    serves historical snapshots on demand.
    - `PaimonCacheSizeEstimator` reserves the post-admission growth of every
    concrete `FileStoreTable` from its schema shape: a base, each field,
    each field nested in a row, each child type of an array / map / multiset
    / vector, each option and each key. The constants are calibrated against
    a retained-size oracle that walks the grown graph (new test helper
    `EstimatorCalibrationAssertions`). A partial-update table reserves
    another 256 bytes per field for its merge function. Every concrete table
    also carries a fixed 16 KB allowance for its FileIO, as on branch-4.x:
    Paimon offers no IO-free way to tell an owned FileIO from one the
    catalog shares, and charging a shared FileIO to each table only makes
    the estimate conservative. The allowance covers an 8,192-character REST
    token and a `ResolvingFileIO` with its backend; a token refresh replaces
    the token rather than adding to it.
    
    **Results.**
    
    | Case | Before | After |
    |---|---|---|
    | `meta.cache.iceberg.manifest.enable=true` plus any weight limit | SDK
    manifest cache derived on, up to 100 MB per FileIO outside the limit |
    not derived; `io.manifest.cache-enabled=true` still turns it on |
    | Weight-limited REST catalog, `snapshot-loading-mode=refs`, table cache
    miss | one extra remote load of the full snapshot list, full history
    retained | no extra load; table not retained, statement reads the live
    table |
    | Paimon table with 301 INT columns, after one scan | 35 KB admitted,
    114 KB retained | 134 KB admitted, 114 KB retained |
    | Paimon table with a 200-field ROW column, after field lookups | 25 KB
    admitted, 117 KB retained | 143 KB admitted, 117 KB retained |
    | Paimon partial-update table with 303 INT columns, after one scan |
    merge function not reserved | 233 KB admitted, 143 KB retained |
    | Paimon table on its own FileIO (REST with or without data tokens,
    Hive) | FileIO and token not charged | 16 KB allowance per table |
    
    Across 13 Paimon fixtures (append and primary-key tables, wide schemas,
    many options, nested rows, arrays and maps of rows, multisets, wide
    partial-update and aggregation tables) the admitted weight without the
    FileIO allowance is now 1.2-1.7x the fully grown graph.
    
    Not in this PR: wide schemas that exceed the 10,000-visit budget of
    `ReflectiveObjectSizeEstimator.estimateComplete` (for example HMS tables
    with about 1,000 primitive columns, or Paimon tables with about 3,400)
    are still never admitted under a limit. The same budget is shared by
    several connectors and the FE schema cache, so it needs its own change.
    
    **Classes, and how they call each other.**
    
    - `IcebergConnector`: `WEIGHTED_CACHE_ENTRIES`,
    `hasMetaCacheWeightLimit()`, and passes the flag at the three catalog
    builds.
    - `IcebergCatalogFactory`: `buildBaseCatalogProperties`,
    `buildCatalogProperties` and `buildS3TablesCatalogProperties` take
    `metaCacheWeightLimited` and stay pure.
    - `IcebergCatalogProperties`: validates `max-weight` keys against the
    shared entry list.
    - `IcebergCacheSizeEstimator` / `IcebergTableCache.TableOwner`: snapshot
    probe and the guard before serialization.
    - `PaimonCacheSizeEstimator`: `estimateStoreGrowth` and
    `estimateTableFileIO` / `estimateFileIO`.
    
    ```
    IcebergConnector.createCatalog / createS3TablesCatalog / createDlfCatalog
      └─ hasMetaCacheWeightLimit(): metaCache.hasEnclosingWeightLimit() || 
cacheSpec(entry).isWeightBounded()
      └─ IcebergCatalogFactory.build*CatalogProperties(..., 
metaCacheWeightLimited)
           └─ appendManifestCacheProperties: derive io.manifest.cache-enabled 
only when not limited
    
    IcebergTableCache.borrow ─ loader ─ new TableOwner(table, ..., weighted)
      └─ areSnapshotsLoaded(table) ? toJson + estimateTable : 
incomplete("iceberg_snapshots_not_loaded")
    
    PaimonMetaCacheCatalog "paimon-table" size estimator
      └─ PaimonCacheSizeEstimator.estimateTable ─ estimateFileStoreTable (each 
concrete table)
           ├─ estimateStoreGrowth(schema)
           └─ estimateTableFileIO(fileIO): fixed allowance, once per FileIO
    ```
    
    Nothing changes for catalogs without a weight limit: the flag is false,
    and the probe and the reservations only run when an entry is weighed.
    Independent of #68839 (tests only); both add tests to
    `IcebergConnectorCacheTest` and `IcebergTableCacheTest`, in different
    places.
    
    Co-authored-by: Calvin Kirs <[email protected]>
---
 .../iceberg/IcebergCacheSizeEstimator.java         |  31 ++
 .../connector/iceberg/IcebergCatalogFactory.java   |  50 +--
 .../iceberg/IcebergCatalogProperties.java          |   2 +-
 .../doris/connector/iceberg/IcebergConnector.java  |  29 +-
 .../doris/connector/iceberg/IcebergTableCache.java |   6 +
 .../iceberg/IcebergCatalogFactoryTest.java         |  66 ++--
 .../iceberg/IcebergCatalogOptionsSnapshotTest.java |   6 +-
 .../iceberg/IcebergConnectorCacheTest.java         |  21 ++
 .../connector/iceberg/IcebergTableCacheTest.java   |  84 +++++
 .../connector/paimon/PaimonCacheSizeEstimator.java | 113 ++++++-
 .../paimon/EstimatorCalibrationAssertions.java     | 181 +++++++++++
 .../paimon/PaimonCacheSizeEstimatorTest.java       | 351 +++++++++++++++++++++
 12 files changed, 893 insertions(+), 47 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCacheSizeEstimator.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCacheSizeEstimator.java
index 3b88516b5b5..fdffb7dc184 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCacheSizeEstimator.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCacheSizeEstimator.java
@@ -54,6 +54,7 @@ import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.Types;
 
+import java.lang.reflect.Field;
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -91,6 +92,8 @@ final class IcebergCacheSizeEstimator {
     private static final long HASH_MAP_NODE_SHALLOW_BYTES = 
classSize("java.util.HashMap$Node");
     private static final long LINKED_HASH_MAP_ENTRY_SHALLOW_BYTES = 
classSize("java.util.LinkedHashMap$Entry");
     private static final long CONTENT_FILE_SCHEMA_BYTES = 
estimateContentFileSchema();
+    // TableMetadata does not expose whether a lazily supplied snapshot list 
has been fetched yet.
+    private static final Field TABLE_METADATA_SNAPSHOTS_LOADED = 
tableMetadataField("snapshotsLoaded");
 
     private IcebergCacheSizeEstimator() {
     }
@@ -115,6 +118,24 @@ final class IcebergCacheSizeEstimator {
         return add(bytes, estimateTableMetadata(operations.current()));
     }
 
+    /**
+     * Whether the table's snapshot list is materialized, so serializing or 
weighing its metadata does no catalog IO.
+     * A REST catalog with {@code snapshot-loading-mode=refs} returns metadata 
holding only the referenced snapshots
+     * plus a supplier that loads the complete list remotely on the first 
{@code snapshots()} call. A table without
+     * {@link TableOperations} has no {@link TableMetadata}, so nothing of it 
is loaded lazily.
+     */
+    static boolean areSnapshotsLoaded(Table table) {
+        if (!(table instanceof HasTableOperations)) {
+            return true;
+        }
+        TableMetadata metadata = ((HasTableOperations) 
table).operations().current();
+        try {
+            return TABLE_METADATA_SNAPSHOTS_LOADED.getBoolean(metadata);
+        } catch (IllegalAccessException e) {
+            throw new IllegalStateException("Can not read the snapshot state 
of Iceberg table metadata", e);
+        }
+    }
+
     static long estimateSerializedTableMetadata(String metadataJson) {
         return JvmSizeUtils.stringSize(metadataJson);
     }
@@ -728,6 +749,16 @@ final class IcebergCacheSizeEstimator {
         }
     }
 
+    private static Field tableMetadataField(String name) {
+        try {
+            Field field = TableMetadata.class.getDeclaredField(name);
+            field.setAccessible(true);
+            return field;
+        } catch (NoSuchFieldException e) {
+            throw new IllegalStateException("Required Iceberg TableMetadata 
field is missing: " + name, e);
+        }
+    }
+
     private static long add(long left, long right) {
         return JvmSizeUtils.saturatedAdd(left, right);
     }
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogFactory.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogFactory.java
index 054bfb76ea8..6374647c60f 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogFactory.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogFactory.java
@@ -142,16 +142,18 @@ public final class IcebergCatalogFactory {
      * raw props (legacy {@code getOrigProps()} copy-all — arbitrary 
user/iceberg keys pass through to
      * the SDK; the per-flavor appenders + the connector add the derived keys 
on top), (2) map
      * {@code warehouse} to {@link CatalogProperties#WAREHOUSE_LOCATION}, (3) 
add manifest-cache keys.
-     * PURE: depends only on {@code props}. The flavor's {@code catalog-impl} 
and the {@code type}
+     * PURE: depends only on its arguments; {@code metaCacheWeightLimited} is
+     * {@code IcebergConnector#hasMetaCacheWeightLimit}. The flavor's {@code 
catalog-impl} and the {@code type}
      * removal are applied by the caller (the connector / per-flavor path).
      */
-    public static Map<String, String> buildBaseCatalogProperties(Map<String, 
String> props) {
+    public static Map<String, String> buildBaseCatalogProperties(Map<String, 
String> props,
+            boolean metaCacheWeightLimited) {
         Map<String, String> opts = new HashMap<>(props);
         String warehouse = props.get(CatalogProperties.WAREHOUSE_LOCATION);
         if (StringUtils.isNotBlank(warehouse)) {
             opts.put(CatalogProperties.WAREHOUSE_LOCATION, warehouse);
         }
-        appendManifestCacheProperties(props, opts);
+        appendManifestCacheProperties(props, opts, metaCacheWeightLimited);
         return opts;
     }
 
@@ -161,21 +163,31 @@ public final class IcebergCatalogFactory {
      * {@code io.manifest.cache-enabled} directly — derive it to {@code 
"true"} from the FE meta-cache
      * spec ({@code meta.cache.iceberg.manifest.*}) using the same {@code 
enable && ttl != 0 &&
      * capacity != 0} rule. Default-disabled (legacy {@code 
DEFAULT_ICEBERG_MANIFEST_CACHE_ENABLE}).
+     * Under a metadata cache weight limit the SDK cache, which keeps up to
+     * {@code io.manifest.cache.max-total-bytes} of manifest content per 
FileIO outside that limit, is not
+     * derived but switched off explicitly: an Iceberg REST server's {@code 
/config} defaults fill in a key the
+     * client leaves out. The server's overrides and a table's own config 
still take precedence.
      */
-    private static void appendManifestCacheProperties(Map<String, String> 
props, Map<String, String> opts) {
+    private static void appendManifestCacheProperties(Map<String, String> 
props, Map<String, String> opts,
+            boolean metaCacheWeightLimited) {
         boolean hasExplicitEnabled = 
StringUtils.isNotBlank(props.get(CatalogProperties.IO_MANIFEST_CACHE_ENABLED));
         copyIfPresent(props, opts, 
CatalogProperties.IO_MANIFEST_CACHE_ENABLED);
         copyIfPresent(props, opts, 
CatalogProperties.IO_MANIFEST_CACHE_EXPIRATION_INTERVAL_MS);
         copyIfPresent(props, opts, 
CatalogProperties.IO_MANIFEST_CACHE_MAX_TOTAL_BYTES);
         copyIfPresent(props, opts, 
CatalogProperties.IO_MANIFEST_CACHE_MAX_CONTENT_LENGTH);
-        if (!hasExplicitEnabled) {
-            CacheSpec spec = CacheSpec.fromProperties(props,
-                    IcebergConnector.MANIFEST_CACHE_ENABLE, 
DEFAULT_MANIFEST_CACHE_ENABLE,
-                    IcebergConnector.MANIFEST_CACHE_TTL_SECOND, 
DEFAULT_MANIFEST_CACHE_TTL_SECOND,
-                    IcebergConnector.MANIFEST_CACHE_CAPACITY, 
DEFAULT_MANIFEST_CACHE_CAPACITY);
-            if (CacheSpec.isCacheEnabled(spec.isEnable(), spec.getTtlSecond(), 
spec.getCapacity())) {
-                opts.put(CatalogProperties.IO_MANIFEST_CACHE_ENABLED, "true");
-            }
+        if (hasExplicitEnabled) {
+            return;
+        }
+        if (metaCacheWeightLimited) {
+            opts.put(CatalogProperties.IO_MANIFEST_CACHE_ENABLED, "false");
+            return;
+        }
+        CacheSpec spec = CacheSpec.fromProperties(props,
+                IcebergConnector.MANIFEST_CACHE_ENABLE, 
DEFAULT_MANIFEST_CACHE_ENABLE,
+                IcebergConnector.MANIFEST_CACHE_TTL_SECOND, 
DEFAULT_MANIFEST_CACHE_TTL_SECOND,
+                IcebergConnector.MANIFEST_CACHE_CAPACITY, 
DEFAULT_MANIFEST_CACHE_CAPACITY);
+        if (CacheSpec.isCacheEnabled(spec.isEnable(), spec.getTtlSecond(), 
spec.getCapacity())) {
+            opts.put(CatalogProperties.IO_MANIFEST_CACHE_ENABLED, "true");
         }
     }
 
@@ -376,7 +388,8 @@ public final class IcebergCatalogFactory {
      * the common base (copy-all + warehouse + manifest cache), the flavor's 
{@code catalog-impl}, the
      * per-flavor derivations, the S3FileIO dialect (for rest/hadoop/jdbc; 
glue emits its own), the jdbc
      * {@code catalog_name} positional removal, and finally the removal of the 
{@code type} key (the iceberg SDK
-     * forbids both {@code type} and {@code catalog-impl}). PURE: a function 
of {@code props} + {@code chosenS3}.
+     * forbids both {@code type} and {@code catalog-impl}). PURE: a function 
of {@code props} + {@code chosenS3} +
+     * {@code metaCacheWeightLimited} (see {@link 
#buildBaseCatalogProperties}).
      *
      * <p>The metastore connection (HMS {@code HiveConf}) and storage {@code 
Configuration} are SEPARATE sinks
      * built by the connector ({@link #assembleHiveConf} / {@link 
#buildHadoopConfiguration}); they are not part
@@ -384,10 +397,10 @@ public final class IcebergCatalogFactory {
      * impl only here (the existing skeleton behavior), so this method covers 
exactly the five SDK-built flavors.
      */
     public static Map<String, String> 
buildCatalogProperties(IcebergCatalogProperties catalogProps,
-            Optional<S3CompatibleFileSystemProperties> chosenS3) {
+            Optional<S3CompatibleFileSystemProperties> chosenS3, boolean 
metaCacheWeightLimited) {
         Map<String, String> props = catalogProps.getRaw();
         String flavor = catalogProps.getFlavor();
-        Map<String, String> opts = buildBaseCatalogProperties(props);
+        Map<String, String> opts = buildBaseCatalogProperties(props, 
metaCacheWeightLimited);
         opts.put(CatalogProperties.CATALOG_IMPL, resolveCatalogImpl(flavor));
         switch (flavor) {
             case IcebergCatalogProperties.TYPE_REST:
@@ -431,7 +444,8 @@ public final class IcebergCatalogFactory {
      * plus the {@code S3FileIO} dialect ({@code client.region} + {@code 
s3.*}) and the EXPLICIT-wins credential
      * block ({@link #appendS3TablesFileIOProperties}) — which, unlike the 
generic rest/hadoop/jdbc FileIO path,
      * suppresses the assume-role keys when static AK/SK are present (legacy 
{@code putS3FileIOCredentialProperties}
-     * returns early for EXPLICIT). PURE: a function of {@code props} + {@code 
chosenS3}.
+     * returns early for EXPLICIT). PURE: a function of {@code props} + {@code 
chosenS3} +
+     * {@code metaCacheWeightLimited} (see {@link 
#buildBaseCatalogProperties}).
      *
      * <p>Unlike {@link #buildCatalogProperties}, this does NOT add a {@code 
catalog-impl} and does NOT remove the
      * {@code type} key: s3tables is built by the connector via {@code new 
S3TablesCatalog().initialize(name, opts,
@@ -442,9 +456,9 @@ public final class IcebergCatalogFactory {
      * connector ({@code IcebergConnector.buildS3TablesClient}); it is not 
part of this pure options map.
      */
     public static Map<String, String> 
buildS3TablesCatalogProperties(IcebergCatalogProperties catalogProps,
-            Optional<S3CompatibleFileSystemProperties> chosenS3) {
+            Optional<S3CompatibleFileSystemProperties> chosenS3, boolean 
metaCacheWeightLimited) {
         Map<String, String> props = catalogProps.getRaw();
-        Map<String, String> opts = buildBaseCatalogProperties(props);
+        Map<String, String> opts = buildBaseCatalogProperties(props, 
metaCacheWeightLimited);
         if (chosenS3.isPresent()) {
             appendS3TablesFileIOProperties(opts, chosenS3.get(), props);
         } else {
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogProperties.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogProperties.java
index 0774e131a16..fe9844a82c3 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogProperties.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogProperties.java
@@ -163,7 +163,7 @@ public final class IcebergCatalogProperties {
     private static void checkMetaCacheProperties(
             Map<String, String> properties, Map<String, String> 
submittedProperties) {
         CacheSpec.checkWeightProperties(properties, submittedProperties, 
"iceberg",
-                "table", "partition", "manifest", "partition_view");
+                IcebergConnector.WEIGHTED_CACHE_ENTRIES);
         
CacheSpec.checkBooleanProperty(properties.get(IcebergConnector.TABLE_CACHE_ENABLE),
                 IcebergConnector.TABLE_CACHE_ENABLE);
         
CacheSpec.checkLongProperty(properties.get(IcebergConnector.TABLE_CACHE_TTL_SECOND),
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
index d26b359c601..8e910135238 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
@@ -161,6 +161,8 @@ public class IcebergConnector implements Connector {
     static final String MANIFEST_CACHE_CAPACITY = 
"meta.cache.iceberg.manifest.capacity";
     static final long DEFAULT_TABLE_CACHE_TTL_SECOND = 86400L;
     static final int DEFAULT_TABLE_CACHE_CAPACITY = 1000;
+    // Entries whose meta.cache.iceberg.<entry>.max-weight this connector 
reads.
+    static final String[] WEIGHTED_CACHE_ENTRIES = {"table", "partition", 
"manifest", "partition_view"};
 
     // Doris storage property keys (mirror StorageProperties without a fe-core 
dependency).
     // Catalog property key gating the plugin-side Kerberos authenticator 
(value matches AuthType.KERBEROS).
@@ -370,6 +372,24 @@ public class IcebergConnector implements Connector {
         return CacheSpec.fromProperties(cacheProperties, "iceberg", entryName, 
defaults);
     }
 
+    /**
+     * Whether a metadata cache weight limit applies to this catalog: the 
FE-global limit, the catalog's
+     * {@code meta.cache.max-weight}, or the {@code max-weight} of one of 
{@link #WEIGHTED_CACHE_ENTRIES}. The
+     * catalog options then leave the Iceberg SDK manifest content cache off 
unless the user enables it, because
+     * that cache keeps manifests per FileIO outside every Doris limit.
+     */
+    boolean hasMetaCacheWeightLimit() {
+        if (metaCache.hasEnclosingWeightLimit()) {
+            return true;
+        }
+        for (String entry : WEIGHTED_CACHE_ENTRIES) {
+            if (cacheSpec(entry).isWeightBounded()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     @Override
     public ConnectorMetadata getMetadata(ConnectorSession session) {
         return new IcebergConnectorMetadata(newCatalogBackedOps(session), 
catalogProps, context,
@@ -1032,7 +1052,7 @@ public class IcebergConnector implements Connector {
         }
 
         Map<String, String> catalogOptions =
-                IcebergCatalogFactory.buildCatalogProperties(catalogProps, 
chosenS3);
+                IcebergCatalogFactory.buildCatalogProperties(catalogProps, 
chosenS3, hasMetaCacheWeightLimit());
         Map<String, String> storageHadoopConfig = buildStorageHadoopConfig();
 
         Configuration conf;
@@ -1107,7 +1127,8 @@ public class IcebergConnector implements Connector {
         DlfMetaStoreProperties dlf = (DlfMetaStoreProperties) 
MetaStoreProviders.bindForType(
                 IcebergCatalogProperties.TYPE_DLF, properties, 
buildStorageHadoopConfig());
         Configuration conf = 
IcebergCatalogFactory.buildDlfConfiguration(dlf.toDlfCatalogConf());
-        Map<String, String> catalogOptions = 
IcebergCatalogFactory.buildBaseCatalogProperties(properties);
+        Map<String, String> catalogOptions =
+                IcebergCatalogFactory.buildBaseCatalogProperties(properties, 
hasMetaCacheWeightLimit());
         return buildCatalogAuthenticated(IcebergCatalogProperties.TYPE_DLF, () 
-> {
             DLFCatalog catalog = new DLFCatalog(chosenS3.get());
             catalog.setConf(conf);
@@ -1232,8 +1253,8 @@ public class IcebergConnector implements Connector {
      */
     private Catalog createS3TablesCatalog(String catalogName, 
Optional<S3CompatibleFileSystemProperties> chosenS3) {
         String region = resolveS3TablesRegion(chosenS3, properties);
-        Map<String, String> catalogOptions =
-                
IcebergCatalogFactory.buildS3TablesCatalogProperties(catalogProps, chosenS3);
+        Map<String, String> catalogOptions = 
IcebergCatalogFactory.buildS3TablesCatalogProperties(
+                catalogProps, chosenS3, hasMetaCacheWeightLimit());
         LOG.info("Creating Iceberg s3tables catalog '{}' region='{}' 
boundStorage={}",
                 catalogName, region, chosenS3.isPresent());
         return 
buildCatalogAuthenticated(IcebergCatalogProperties.TYPE_S3_TABLES, () -> {
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
index 43be412bd04..5526a52587d 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
@@ -270,6 +270,12 @@ final class IcebergTableCache {
             this.sizeEstimate = estimateWeight
                     ? 
MetaCacheSizeEstimator.estimateSafely("iceberg_table_estimator_failure",
                             () -> {
+                                if 
(!IcebergCacheSizeEstimator.areSnapshotsLoaded(table)) {
+                                    // Serializing or weighing a lazily 
supplied snapshot list would fetch every
+                                    // snapshot remotely on each miss. Decline 
retention; borrowers keep reading
+                                    // the live table, whose supplier still 
serves historical snapshots.
+                                    return 
MetaCacheSizeEstimate.incomplete("iceberg_snapshots_not_loaded");
+                                }
                                 long serializedMetadataBytes = 0L;
                                 if (table instanceof BaseTable) {
                                     TableMetadata metadata = ((BaseTable) 
table).operations().current();
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogFactoryTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogFactoryTest.java
index 26921878e6b..6a3f31dbb1f 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogFactoryTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogFactoryTest.java
@@ -27,6 +27,7 @@ import 
org.apache.doris.filesystem.properties.StorageProperties;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.hive.conf.HiveConf;
 import org.apache.iceberg.aws.AwsClientProperties;
+import org.apache.iceberg.rest.responses.ConfigResponse;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
@@ -177,7 +178,7 @@ public class IcebergCatalogFactoryTest {
         // CatalogProperties.WAREHOUSE_LOCATION ("warehouse"). MUTATION: 
dropping the copy-all (selective
         // re-key) loses "foo"; a wrong warehouse key loses "warehouse" -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildBaseCatalogProperties(
-                props("iceberg.catalog.type", "hadoop", "warehouse", 
"s3://b/wh", "foo", "bar"));
+                props("iceberg.catalog.type", "hadoop", "warehouse", 
"s3://b/wh", "foo", "bar"), false);
         Assertions.assertEquals("bar", opts.get("foo"));
         Assertions.assertEquals("s3://b/wh", opts.get("warehouse"));
     }
@@ -188,7 +189,7 @@ public class IcebergCatalogFactoryTest {
         // io.manifest.cache-enabled and no 
meta.cache.iceberg.manifest.enable, the key must stay ABSENT
         // (not default-on). MUTATION: unconditionally putting "true" -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildBaseCatalogProperties(
-                props("iceberg.catalog.type", "rest"));
+                props("iceberg.catalog.type", "rest"), false);
         Assertions.assertNull(opts.get("io.manifest.cache-enabled"));
     }
 
@@ -199,7 +200,7 @@ public class IcebergCatalogFactoryTest {
         // The key is DOTTED ("io.manifest.cache-enabled"); the recon agent 
guessed a hyphenated spelling.
         // MUTATION: wrong key spelling OR skipping the derivation -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildBaseCatalogProperties(
-                props("iceberg.catalog.type", "rest", 
"meta.cache.iceberg.manifest.enable", "true"));
+                props("iceberg.catalog.type", "rest", 
"meta.cache.iceberg.manifest.enable", "true"), false);
         Assertions.assertEquals("true", opts.get("io.manifest.cache-enabled"));
     }
 
@@ -210,7 +211,7 @@ public class IcebergCatalogFactoryTest {
         // the derivation overwrite it to "true" -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildBaseCatalogProperties(
                 props("iceberg.catalog.type", "rest", 
"io.manifest.cache-enabled", "false",
-                        "meta.cache.iceberg.manifest.enable", "true"));
+                        "meta.cache.iceberg.manifest.enable", "true"), false);
         Assertions.assertEquals("false", 
opts.get("io.manifest.cache-enabled"));
     }
 
@@ -221,10 +222,39 @@ public class IcebergCatalogFactoryTest {
         // MUTATION: deriving on enable alone (ignoring ttl/capacity==0) -> 
"true" -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildBaseCatalogProperties(
                 props("iceberg.catalog.type", "rest", 
"meta.cache.iceberg.manifest.enable", "true",
-                        "meta.cache.iceberg.manifest.ttl-second", "0"));
+                        "meta.cache.iceberg.manifest.ttl-second", "0"), false);
         Assertions.assertNull(opts.get("io.manifest.cache-enabled"));
     }
 
+    @Test
+    public void metaCacheWeightLimitTurnsTheSdkManifestCacheOff() {
+        // WHY: the SDK manifest content cache keeps up to 
io.manifest.cache.max-total-bytes of manifests per FileIO
+        // outside every Doris metadata cache weight limit, so under a limit 
it is not derived from
+        // meta.cache.iceberg.manifest.* (branch-4.x 
AbstractIcebergProperties) but switched off outright, because
+        // an Iceberg REST server's /config defaults fill in a key the client 
leaves out. An explicit user value
+        // still wins. MUTATION: ignoring the limit flag -> "true" is derived 
-> red; leaving the key out -> the
+        // REST server's default turns the cache on -> red.
+        Map<String, String> metaCacheOnly = props("iceberg.catalog.type", 
"hadoop", "warehouse", "s3://b/wh",
+                "meta.cache.iceberg.manifest.enable", "true");
+        Assertions.assertEquals("false", 
IcebergCatalogFactory.buildBaseCatalogProperties(metaCacheOnly, true)
+                .get("io.manifest.cache-enabled"));
+        Assertions.assertEquals("false", 
IcebergCatalogFactory.buildCatalogProperties(
+                IcebergCatalogProperties.of(metaCacheOnly), Optional.empty(), 
true).get("io.manifest.cache-enabled"));
+
+        Map<String, String> rest = 
IcebergCatalogFactory.buildCatalogProperties(
+                IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"rest", "uri", "https://rest";)),
+                Optional.empty(), true);
+        ConfigResponse serverDefaultsOn = ConfigResponse.builder()
+                .withDefault("io.manifest.cache-enabled", "true").build();
+        Assertions.assertEquals("false", 
serverDefaultsOn.merge(rest).get("io.manifest.cache-enabled"),
+                "a REST server's /config default must not turn the SDK 
manifest cache back on");
+
+        Map<String, String> explicit = new HashMap<>(metaCacheOnly);
+        explicit.put("io.manifest.cache-enabled", "true");
+        Assertions.assertEquals("true", 
IcebergCatalogFactory.buildBaseCatalogProperties(explicit, true)
+                .get("io.manifest.cache-enabled"));
+    }
+
     // ---------------------------------------------------------------------
     // appendS3FileIOProperties — storage creds -> iceberg S3FileIO dialect 
(D-061)
     // ---------------------------------------------------------------------
@@ -471,7 +501,7 @@ public class IcebergCatalogFactoryTest {
                         .accessKey("OSS_AK")
                         .secretKey("OSS_SK")
                         .sessionToken("OSS_TOKEN")
-                        .usePathStyle("false")));
+                        .usePathStyle("false")), false);
 
         
Assertions.assertEquals("https://cn-hangzhou.oss-tables.aliyuncs.com/iceberg";, 
opts.get("uri"));
         
Assertions.assertEquals("acs:osstables:cn-hangzhou:1234567890:bucket/my-table-bucket",
@@ -764,7 +794,7 @@ public class IcebergCatalogFactoryTest {
         // iceberg.catalog.type key is a separate raw key carried by copy-all 
and is harmless. MUTATION: not
         // setting impl, or leaving "type" -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildCatalogProperties(
-                IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"hadoop", "warehouse", "s3://b/wh", "type", "hadoop")), Optional.empty());
+                IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"hadoop", "warehouse", "s3://b/wh", "type", "hadoop")), Optional.empty(), 
false);
         Assertions.assertEquals("org.apache.iceberg.hadoop.HadoopCatalog", 
opts.get("catalog-impl"));
         Assertions.assertNull(opts.get("type"), "the SDK 'type' key must be 
removed before building");
     }
@@ -775,7 +805,7 @@ public class IcebergCatalogFactoryTest {
         // initCatalog. MUTATION: leaving it in the map -> red (iceberg would 
treat it as an unknown option).
         Map<String, String> opts = 
IcebergCatalogFactory.buildCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"jdbc", "uri", "jdbc:mysql://h/db", "warehouse", "s3://b/wh",
-                        "iceberg.jdbc.catalog_name", "mycat")), 
Optional.empty());
+                        "iceberg.jdbc.catalog_name", "mycat")), 
Optional.empty(), false);
         Assertions.assertEquals("org.apache.iceberg.jdbc.JdbcCatalog", 
opts.get("catalog-impl"));
         Assertions.assertNull(opts.get("iceberg.jdbc.catalog_name"),
                 "the jdbc catalog_name must be consumed positionally, not left 
in the options map");
@@ -787,7 +817,7 @@ public class IcebergCatalogFactoryTest {
         // the s3.* options. MUTATION: appending the base S3FileIO for HMS -> 
s3.endpoint present -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"hms")),
-                Optional.of(new 
FakeS3CompatibleStorageProperties("S3").endpoint("https://s3";).accessKey("AK")));
+                Optional.of(new 
FakeS3CompatibleStorageProperties("S3").endpoint("https://s3";).accessKey("AK")),
 false);
         Assertions.assertEquals(DorisHiveCatalog.class.getName(), 
opts.get("catalog-impl"));
         Assertions.assertNull(opts.get("s3.endpoint"), "HMS must not emit 
S3FileIO options");
         Assertions.assertNull(opts.get("s3.access-key-id"));
@@ -803,7 +833,7 @@ public class IcebergCatalogFactoryTest {
         Map<String, String> opts = 
IcebergCatalogFactory.buildCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"rest", "uri", "https://rest";,
                         "iceberg.rest.vended-credentials-enabled", "true", 
"s3.endpoint", "https://minio:9000";,
-                        "s3.region", "us-east-1")), Optional.empty());
+                        "s3.region", "us-east-1")), Optional.empty(), false);
         Assertions.assertEquals("us-east-1", opts.get("client.region"),
                 "vended REST (no bound S3) must still translate s3.region -> 
client.region");
     }
@@ -818,14 +848,14 @@ public class IcebergCatalogFactoryTest {
         // iceberg.rest.signing-region is absent from the narrow 4-alias set 
-> client.region null.
         Map<String, String> viaAwsRegion = 
IcebergCatalogFactory.buildCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"rest", "uri", "https://rest";,
-                        "iceberg.rest.vended-credentials-enabled", "true", 
"AWS_REGION", "us-east-1")), Optional.empty());
+                        "iceberg.rest.vended-credentials-enabled", "true", 
"AWS_REGION", "us-east-1")), Optional.empty(), false);
         Assertions.assertEquals("us-east-1", viaAwsRegion.get("client.region"),
                 "region supplied only via AWS_REGION must translate to 
client.region");
 
         Map<String, String> viaSigningRegion = 
IcebergCatalogFactory.buildCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"rest", "uri", "https://rest";,
                         "iceberg.rest.vended-credentials-enabled", "true",
-                        "iceberg.rest.signing-region", "eu-west-1")), 
Optional.empty());
+                        "iceberg.rest.signing-region", "eu-west-1")), 
Optional.empty(), false);
         Assertions.assertEquals("eu-west-1", 
viaSigningRegion.get("client.region"),
                 "region supplied only via iceberg.rest.signing-region must 
translate to client.region");
     }
@@ -845,7 +875,7 @@ public class IcebergCatalogFactoryTest {
                         "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b")),
                 Optional.of(new FakeS3CompatibleStorageProperties("S3")
                         
.endpoint("https://s3.us-east-1.amazonaws.com";).region("us-east-1")
-                        
.accessKey("AK").secretKey("SK").sessionToken("TK").usePathStyle("true")));
+                        
.accessKey("AK").secretKey("SK").sessionToken("TK").usePathStyle("true")), 
false);
         Assertions.assertEquals("arn:aws:s3tables:us-east-1:1:bucket/b", 
opts.get("warehouse"),
                 "the table-bucket ARN warehouse must be carried through for 
the 3-arg initialize");
         Assertions.assertEquals("AK", opts.get("s3.access-key-id"));
@@ -869,7 +899,7 @@ public class IcebergCatalogFactoryTest {
         Map<String, String> opts = 
IcebergCatalogFactory.buildS3TablesCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"s3tables", "warehouse", "arn:aws:s3tables:us-west-2:1:bucket/b")),
                 Optional.of(new 
FakeS3CompatibleStorageProperties("S3").region("us-west-2")
-                        .roleArn("arn:aws:iam::1:role/r").externalId("eid")));
+                        .roleArn("arn:aws:iam::1:role/r").externalId("eid")), 
false);
         
Assertions.assertEquals("org.apache.iceberg.aws.AssumeRoleAwsClientFactory", 
opts.get("client.factory"));
         Assertions.assertEquals("arn:aws:iam::1:role/r", 
opts.get("client.assume-role.arn"));
         Assertions.assertEquals("us-west-2", 
opts.get("client.assume-role.region"));
@@ -886,7 +916,7 @@ public class IcebergCatalogFactoryTest {
         Map<String, String> opts = 
IcebergCatalogFactory.buildS3TablesCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"s3tables", "warehouse", "arn:aws:s3tables:us-west-2:1:bucket/b",
                         "s3.credentials_provider_type", "ENV")),
-                Optional.of(new 
FakeS3CompatibleStorageProperties("S3").region("us-west-2")));
+                Optional.of(new 
FakeS3CompatibleStorageProperties("S3").region("us-west-2")), false);
         
Assertions.assertEquals(EnvironmentVariableCredentialsProvider.class.getName(),
                 opts.get("client.credentials-provider"));
         Assertions.assertNull(opts.get("client.factory"), "no role -> no 
assume-role block");
@@ -902,7 +932,7 @@ public class IcebergCatalogFactoryTest {
         Map<String, String> opts = 
IcebergCatalogFactory.buildS3TablesCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"s3tables", "warehouse", "arn:aws:s3tables:us-west-2:1:bucket/b")),
                 Optional.of(new 
FakeS3CompatibleStorageProperties("S3").region("us-west-2")
-                        
.accessKey("AK").secretKey("SK").roleArn("arn:aws:iam::1:role/r")));
+                        
.accessKey("AK").secretKey("SK").roleArn("arn:aws:iam::1:role/r")), false);
         Assertions.assertEquals("AK", opts.get("s3.access-key-id"));
         Assertions.assertEquals("SK", opts.get("s3.secret-access-key"));
         Assertions.assertNull(opts.get("client.factory"),
@@ -918,7 +948,7 @@ public class IcebergCatalogFactoryTest {
         // 
buildS3TablesCatalogPropertiesPropagatesClientRegionWithoutBoundS3.) MUTATION: 
fabricating any s3.* -> red.
         Map<String, String> opts = 
IcebergCatalogFactory.buildS3TablesCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"s3tables", "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b")),
-                Optional.empty());
+                Optional.empty(), false);
         Assertions.assertEquals("arn:aws:s3tables:us-east-1:1:bucket/b", 
opts.get("warehouse"));
         Assertions.assertNull(opts.get("s3.access-key-id"));
         Assertions.assertNull(opts.get("client.region"));
@@ -934,7 +964,7 @@ public class IcebergCatalogFactoryTest {
         Map<String, String> opts = 
IcebergCatalogFactory.buildS3TablesCatalogProperties(
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"s3tables", "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b",
                         "s3.region", "us-east-1")),
-                Optional.empty());
+                Optional.empty(), false);
         Assertions.assertEquals("us-east-1", opts.get("client.region"),
                 "no-storage s3tables must propagate s3.region -> client.region 
for the data-plane S3FileIO");
         Assertions.assertNull(opts.get("s3.access-key-id"), "no credentials 
are bound");
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogOptionsSnapshotTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogOptionsSnapshotTest.java
index c0f918d29a2..cd3b22b0d4f 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogOptionsSnapshotTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCatalogOptionsSnapshotTest.java
@@ -64,7 +64,7 @@ public class IcebergCatalogOptionsSnapshotTest {
     private static void assertOptions(Map<String, String> expected, 
Map<String, String> input,
             Optional<S3CompatibleFileSystemProperties> chosenS3) {
         Assertions.assertEquals(new TreeMap<>(expected),
-                new 
TreeMap<>(IcebergCatalogFactory.buildCatalogProperties(IcebergCatalogProperties.of(input),
 chosenS3)));
+                new 
TreeMap<>(IcebergCatalogFactory.buildCatalogProperties(IcebergCatalogProperties.of(input),
 chosenS3, false)));
     }
 
     private static Optional<S3CompatibleFileSystemProperties> 
s3(FakeS3CompatibleStorageProperties fake) {
@@ -586,7 +586,7 @@ public class IcebergCatalogOptionsSnapshotTest {
                         "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b",
                         "s3.role_arn", "arn:aws:iam::1:role/ignored")),
                 Optional.of(new 
FakeS3CompatibleStorageProperties("S3").region("us-east-1")
-                        
.accessKey("AK").secretKey("SK").roleArn("arn:aws:iam::1:role/ignored")));
+                        
.accessKey("AK").secretKey("SK").roleArn("arn:aws:iam::1:role/ignored")), 
false);
         Assertions.assertEquals(new TreeMap<>(expected), new 
TreeMap<>(actual));
     }
 
@@ -601,7 +601,7 @@ public class IcebergCatalogOptionsSnapshotTest {
                 IcebergCatalogProperties.of(props("iceberg.catalog.type", 
"s3tables",
                         "warehouse", "arn:aws:s3tables:us-east-1:1:bucket/b",
                         "s3.region", "us-east-1")),
-                Optional.empty());
+                Optional.empty(), false);
         Assertions.assertEquals(new TreeMap<>(expected), new 
TreeMap<>(actual));
     }
 }
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java
index ec352b02ca4..74ec5b7589b 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java
@@ -129,6 +129,27 @@ public class IcebergConnectorCacheTest {
                         new 
RecordingConnectorContext()).schemaCacheTtlSecondOverride());
     }
 
+    @Test
+    public void metaCacheWeightLimitSeesCatalogAndEveryConsumedEntryLimit() 
throws Exception {
+        // The SDK manifest content cache is derived on only when no Doris 
weight limit applies to the catalog. Keys
+        // that create no budget (an entry this connector does not read, an 
unparsable persisted value) do not count.
+        // MUTATION: checking only the catalog limit, or dropping an entry 
from the list -> red.
+        assertMetaCacheWeightLimit(false, Collections.emptyMap());
+        assertMetaCacheWeightLimit(true, props("meta.cache.max-weight", 
"128MB"));
+        for (String entry : IcebergConnector.WEIGHTED_CACHE_ENTRIES) {
+            assertMetaCacheWeightLimit(true, props("meta.cache.iceberg." + 
entry + ".max-weight", "16MB"));
+        }
+        assertMetaCacheWeightLimit(false, 
props("meta.cache.iceberg.snapshot.max-weight", "16MB"));
+        assertMetaCacheWeightLimit(false, props("meta.cache.max-weight", 
"not-a-size"));
+    }
+
+    private static void assertMetaCacheWeightLimit(boolean expected, 
Map<String, String> properties)
+            throws Exception {
+        try (IcebergConnector connector = new IcebergConnector(properties, new 
RecordingConnectorContext())) {
+            Assertions.assertEquals(expected, 
connector.hasMetaCacheWeightLimit(), properties.toString());
+        }
+    }
+
     @Test
     public void invalidateHooksAreNoThrowOnFreshConnector() {
         // Smoke: the REFRESH TABLE / REFRESH CATALOG hooks must be safe to 
call (they only touch the
diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
index 9d1218c9ef5..8ee2b2e6db6 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java
@@ -48,6 +48,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import java.util.Collections;
+import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -414,6 +415,89 @@ public class IcebergTableCacheTest {
         }
     }
 
+    @Test
+    public void weightedPublicationDoesNotFetchLazilySuppliedSnapshots() {
+        // REST snapshot-loading-mode=refs keeps the full snapshot list behind 
a remote supplier. Serialization and
+        // estimation both read snapshots(), so weighted publication declines 
the table instead of fetching the list
+        // on every miss; the borrower still reaches history through the 
supplier. MUTATION: dropping the guard ->
+        // the supplier runs during publication -> red.
+        AtomicInteger snapshotLoads = new AtomicInteger();
+        Table table = tableWithLazySnapshots(snapshotLoads);
+        try (CatalogMetaCache owner = CatalogMetaCache.unmanaged()) {
+            IcebergTableCache cache = new IcebergTableCache(
+                    owner, CacheSpec.ofWeight(true, 100L, 1000L, 10L * 1024L * 
1024L),
+                    ignored -> () -> { }, new IcebergCatalogResourceTracker());
+            try (IcebergTableCache.TableLease lease = cache.borrow(id(), () -> 
table)) {
+                Assertions.assertEquals(0, snapshotLoads.get(), "publication 
must not fetch the snapshot list");
+                Assertions.assertEquals(0, cache.size());
+                
Assertions.assertEquals("incomplete_estimate:iceberg_snapshots_not_loaded",
+                        
owner.entries().get("iceberg-table").metrics().getLastWeightRejectReason());
+
+                Table statement = lease.snapshotReadTable();
+                Assertions.assertEquals(102L, 
statement.currentSnapshot().snapshotId());
+                Assertions.assertEquals(0, snapshotLoads.get());
+                Assertions.assertEquals(101L, 
statement.snapshot(101L).snapshotId());
+                Assertions.assertEquals(1, snapshotLoads.get());
+            }
+            // Once the list has been fetched the same metadata is weighed and 
retained like any other.
+            try (IcebergTableCache.TableLease lease = cache.borrow(id(), () -> 
table)) {
+                Assertions.assertEquals(1, cache.size());
+            }
+            Assertions.assertEquals(1, snapshotLoads.get());
+        }
+    }
+
+    @Test
+    public void 
unweightedCacheRetainsLazilySuppliedSnapshotsWithoutFetchingThem() {
+        AtomicInteger snapshotLoads = new AtomicInteger();
+        Table table = tableWithLazySnapshots(snapshotLoads);
+        IcebergTableCache cache = new IcebergTableCache(100L, 1000);
+
+        Assertions.assertSame(table, cache.getOrLoad(id(), () -> table));
+        Assertions.assertEquals(1, cache.size());
+        Assertions.assertEquals(0, snapshotLoads.get());
+    }
+
+    /** What a REST catalog returns under snapshot-loading-mode=refs: the 
referenced snapshot plus a supplier. */
+    private static Table tableWithLazySnapshots(AtomicInteger snapshotLoads) {
+        Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()));
+        TableMetadata base = TableMetadata.newTableMetadata(
+                schema, PartitionSpec.unpartitioned(), 
"file:///tmp/lazy-snapshots-table", Collections.emptyMap());
+        Snapshot older = SnapshotParser.fromJson("{"
+                + "\"sequence-number\":1,"
+                + "\"snapshot-id\":101,"
+                + "\"timestamp-ms\":1000,"
+                + "\"summary\":{\"operation\":\"append\"},"
+                + "\"manifest-list\":\"file:///tmp/snap-101.avro\","
+                + "\"schema-id\":0}");
+        Snapshot newer = SnapshotParser.fromJson("{"
+                + "\"sequence-number\":2,"
+                + "\"snapshot-id\":102,"
+                + "\"parent-snapshot-id\":101,"
+                + "\"timestamp-ms\":2000,"
+                + "\"summary\":{\"operation\":\"append\"},"
+                + "\"manifest-list\":\"file:///tmp/snap-102.avro\","
+                + "\"schema-id\":0}");
+        TableMetadata full = TableMetadata.buildFrom(base)
+                .upgradeFormatVersion(2)
+                .setBranchSnapshot(older, "main")
+                .setBranchSnapshot(newer, "main")
+                .discardChanges()
+                .build();
+        List<Snapshot> allSnapshots = full.snapshots();
+        // The server drops unreferenced snapshots; the client attaches a 
supplier that loads all of them.
+        TableMetadata refsOnly = TableMetadata.buildFrom(
+                        
TableMetadata.buildFrom(full).suppressHistoricalSnapshots().build())
+                
.withMetadataLocation("file:///tmp/lazy-snapshots.metadata.json")
+                .setSnapshotsSupplier(() -> {
+                    snapshotLoads.incrementAndGet();
+                    return allSnapshots;
+                })
+                .discardChanges()
+                .build();
+        return new BaseTable(new StaticTableOperations(refsOnly), 
"lazy-snapshots");
+    }
+
     @Test
     public void ttlZeroDisablesCachingAlwaysLive() {
         AtomicInteger loads = new AtomicInteger();
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
index 83641254968..e00a1509c69 100644
--- 
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
@@ -21,9 +21,12 @@ 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.CoreOptions;
 import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.privilege.PrivilegedFileStoreTable;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.CatalogEnvironment;
 import org.apache.paimon.table.DelegatedFileStoreTable;
 import org.apache.paimon.table.FallbackReadFileStoreTable;
@@ -33,21 +36,60 @@ 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 org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.MapType;
+import org.apache.paimon.types.MultisetType;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.types.VectorType;
 
 import java.net.URI;
 import java.util.Collections;
 import java.util.IdentityHashMap;
+import java.util.List;
 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.
+ * but their graphs are 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, plus a fixed 
allowance for a FileIO
+ * the table may own.
+ *
+ * <p>A cached value is weighed once, at admission, but a {@link 
FileStoreTable} keeps growing afterwards: the
+ * first {@code latestSnapshot()} or scan builds its transient store (a {@code 
FileStore} holding its own
+ * {@code RowType} copies of the schema and a copy of the options) and field 
lookups build the four lazy index
+ * maps of every {@code RowType}. That growth is reserved from the schema's 
shape. On Paimon 1.4.2 it measures
+ * about 1-2 KB per table, 200-270 bytes per field, another 200 bytes per 
field nested in a row, 40 bytes per
+ * option and, for a partial-update table's merge function, about 150 more 
bytes per field; the constants below
+ * keep a margin above that.
  */
 final class PaimonCacheSizeEstimator {
+    private static final long STORE_GROWTH_BASE_BYTES = 2048L;
+    private static final long STORE_GROWTH_FIELD_BYTES = 320L;
+    private static final long STORE_GROWTH_NESTED_FIELD_BYTES = 256L;
+    // A child type of an array, map, multiset or vector: the store's copy of 
it.
+    private static final long STORE_GROWTH_TYPE_NODE_BYTES = 128L;
+    private static final long STORE_GROWTH_OPTION_BYTES = 64L;
+    // A partition, primary or bucket key: its slot in the derived key types; 
a primary key is also copied as a
+    // renamed key field.
+    private static final long STORE_GROWTH_KEY_BYTES = 192L;
+    // A partial-update table's merge function factory, built with its store, 
keeps sequence-group and aggregator
+    // maps for every field: about 150 bytes per field more than the other 
merge engines on Paimon 1.4.2.
+    private static final long STORE_GROWTH_PARTIAL_UPDATE_FIELD_BYTES = 256L;
+    // A table can own its FileIO: a REST catalog gives each table a 
RESTTokenFileIO (holding the vended token it
+    // fetches on first data access, an STS key id, secret and security token 
of about 2 KB) or a ResolvingFileIO,
+    // and an external table of any kind (format, object, Lance and Iceberg 
tables included) always a
+    // ResolvingFileIO; a Hive catalog resolves a FileIO for each table 
location. Their tokens and backend maps
+    // appear only after admission, and Paimon offers no IO-free way to read 
that state or to tell an owned FileIO
+    // from one the catalog shares. Every table, whatever its kind, is 
therefore charged a fixed 16 KB for its
+    // FileIO, several times any STS token seen so far; a token refresh 
replaces the token rather than adding to
+    // it. Charging a catalog-shared FileIO to each table only makes the 
estimate conservative.
+    private static final long FILE_IO_BYTES = 16L * 1024L;
+
     private PaimonCacheSizeEstimator() {
     }
 
@@ -72,6 +114,7 @@ final class PaimonCacheSizeEstimator {
             bytes = add(bytes, 
ReflectiveObjectSizeEstimator.estimateComplete(table.options()));
             bytes = add(bytes, 
ReflectiveObjectSizeEstimator.estimateComplete(table.comment()));
             bytes = add(bytes, JvmSizeUtils.stringSize(location(table)));
+            bytes = add(bytes, estimateFileIO(table.fileIO()));
         }
         return MetaCacheSizeEstimate.complete(bytes);
     }
@@ -98,10 +141,70 @@ final class PaimonCacheSizeEstimator {
                     ((DelegatedFileStoreTable) table).wrapped(), visited));
         }
         bytes = add(bytes, estimateCompleteOnce(table.schema(), visited));
+        bytes = add(bytes, estimateStoreGrowth(table.schema()));
+        bytes = add(bytes, estimateTableFileIO(table.fileIO(), visited));
         bytes = add(bytes, estimatePath(table.location(), visited));
         return add(bytes, 
estimateCatalogEnvironment(table.catalogEnvironment(), visited));
     }
 
+    /** Reserves the store and index graph this table builds after admission; 
no IO, no store access. */
+    private static long estimateStoreGrowth(TableSchema schema) {
+        long bytes = add(STORE_GROWTH_BASE_BYTES,
+                multiply(schema.options().size(), STORE_GROWTH_OPTION_BYTES));
+        long keys = (long) schema.partitionKeys().size() + 
schema.primaryKeys().size() + schema.bucketKeys().size();
+        bytes = add(bytes, multiply(keys, STORE_GROWTH_KEY_BYTES));
+        for (String primaryKey : schema.primaryKeys()) {
+            bytes = add(bytes, JvmSizeUtils.stringSize(primaryKey));
+        }
+        if (CoreOptions.fromMap(schema.options()).mergeEngine() == 
CoreOptions.MergeEngine.PARTIAL_UPDATE) {
+            bytes = add(bytes, multiply(schema.fields().size(), 
STORE_GROWTH_PARTIAL_UPDATE_FIELD_BYTES));
+        }
+        return add(bytes, estimateFieldGrowth(schema.fields(), 
STORE_GROWTH_FIELD_BYTES));
+    }
+
+    private static long estimateFieldGrowth(List<DataField> fields, long 
perFieldBytes) {
+        long bytes = multiply(fields.size(), perFieldBytes);
+        for (DataField field : fields) {
+            bytes = add(bytes, estimateTypeGrowth(field.type()));
+        }
+        return bytes;
+    }
+
+    private static long estimateTypeGrowth(DataType type) {
+        if (type instanceof RowType) {
+            return estimateFieldGrowth(((RowType) type).getFields(),
+                    STORE_GROWTH_FIELD_BYTES + 
STORE_GROWTH_NESTED_FIELD_BYTES);
+        }
+        if (type instanceof ArrayType) {
+            return estimateChildTypeGrowth(((ArrayType) 
type).getElementType());
+        }
+        if (type instanceof MultisetType) {
+            return estimateChildTypeGrowth(((MultisetType) 
type).getElementType());
+        }
+        if (type instanceof VectorType) {
+            return estimateChildTypeGrowth(((VectorType) 
type).getElementType());
+        }
+        if (type instanceof MapType) {
+            MapType mapType = (MapType) type;
+            return add(estimateChildTypeGrowth(mapType.getKeyType()),
+                    estimateChildTypeGrowth(mapType.getValueType()));
+        }
+        return 0L;
+    }
+
+    private static long estimateChildTypeGrowth(DataType child) {
+        return add(STORE_GROWTH_TYPE_NODE_BYTES, estimateTypeGrowth(child));
+    }
+
+    /** The allowance a table carries for its FileIO; the fallback branches of 
one table share theirs. */
+    private static long estimateTableFileIO(FileIO fileIO, Set<Object> 
visited) {
+        return visited.add(fileIO) ? estimateFileIO(fileIO) : 0L;
+    }
+
+    static long estimateFileIO(FileIO fileIO) {
+        return add(JvmSizeUtils.instanceSize(fileIO.getClass()), 
FILE_IO_BYTES);
+    }
+
     private static long estimateCompleteOnce(Object value, Set<Object> 
visited) {
         return value == null || !visited.add(value)
                 ? 0L : ReflectiveObjectSizeEstimator.estimateComplete(value);
@@ -160,4 +263,8 @@ final class PaimonCacheSizeEstimator {
     private static long add(long left, long right) {
         return JvmSizeUtils.saturatedAdd(left, right);
     }
+
+    private static long multiply(long left, long right) {
+        return JvmSizeUtils.saturatedMultiply(left, right);
+    }
 }
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/EstimatorCalibrationAssertions.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/EstimatorCalibrationAssertions.java
new file mode 100644
index 00000000000..a384fb53eac
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/EstimatorCalibrationAssertions.java
@@ -0,0 +1,181 @@
+// 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.junit.jupiter.api.Assertions;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Offline calibration checks for the cache weight formulas.
+ *
+ * <p>Estimates are admission weights, not exact retained sizes, so the 
assertions verify order-of-magnitude
+ * sanity rather than byte accuracy: a populated fixture must weigh more than 
an empty one, the growth must not
+ * be under-estimated by more than a small factor, and it must not be absurdly 
over-estimated. The retained-size
+ * oracle walks the object graph with identity dedup and the running JVM's 
layout. Unlike
+ * {@code ReflectiveObjectSizeEstimator} it never gives up: JDK collections 
and maps, whose fields are strongly
+ * encapsulated without {@code --add-opens}, are walked through their public 
API, other inaccessible JDK state
+ * counts as its shallow size, and hidden classes (lambdas) are walked like 
any other class.
+ */
+final class EstimatorCalibrationAssertions {
+    // estimatedDelta / actualDelta must stay within this band.
+    private static final double MIN_ESTIMATE_FACTOR = 0.34D;
+    private static final double MAX_ESTIMATE_FACTOR = 12.0D;
+    private static final long HASH_MAP_NODE_BYTES = hashMapNodeBytes();
+    private static final Map<Class<?>, List<Field>> REFERENCE_FIELD_CACHE = 
new HashMap<>();
+
+    private EstimatorCalibrationAssertions() {
+    }
+
+    static void assertConservativeDelta(String fixture, long emptyEstimate, 
long populatedEstimate,
+            Object emptyGraph, Object populatedGraph) {
+        long actualDelta = graphSize(populatedGraph) - graphSize(emptyGraph);
+        long estimatedDelta = populatedEstimate - emptyEstimate;
+        Assertions.assertTrue(actualDelta > 0L, fixture + " must add retained 
heap");
+        Assertions.assertTrue(estimatedDelta > 0L, fixture + " must add 
estimated weight");
+        Assertions.assertTrue((double) estimatedDelta >= actualDelta * 
MIN_ESTIMATE_FACTOR,
+                fixture + " grossly under-estimates retained heap: estimated=" 
+ estimatedDelta
+                        + ", probed=" + actualDelta);
+        Assertions.assertTrue((double) estimatedDelta <= actualDelta * 
MAX_ESTIMATE_FACTOR,
+                fixture + " absurdly over-estimates retained heap: estimated=" 
+ estimatedDelta
+                        + ", probed=" + actualDelta);
+    }
+
+    /**
+     * Retained size of the graph reachable from {@code graph}. Shared JVM 
state (classes, class loaders, threads,
+     * enum constants, references) is treated as leaves so deltas between two 
fixtures cancel it out.
+     */
+    static synchronized long graphSize(Object graph) {
+        if (graph == null) {
+            return 0L;
+        }
+        IdentityHashMap<Object, Boolean> seen = new IdentityHashMap<>();
+        Deque<Object> pending = new ArrayDeque<>();
+        pending.add(graph);
+        seen.put(graph, Boolean.TRUE);
+        long bytes = 0L;
+        while (!pending.isEmpty()) {
+            Object current = pending.poll();
+            Class<?> type = current.getClass();
+            if (type.isArray()) {
+                int length = Array.getLength(current);
+                Class<?> component = type.getComponentType();
+                if (component.isPrimitive()) {
+                    bytes += JvmSizeUtils.primitiveArraySize(component, 
length);
+                } else {
+                    bytes += JvmSizeUtils.objectArraySize(length);
+                    for (int i = 0; i < length; i++) {
+                        enqueue(Array.get(current, i), seen, pending);
+                    }
+                }
+                continue;
+            }
+            boolean jdkType = type.getModule().isNamed();
+            if (jdkType && current instanceof String) {
+                bytes += JvmSizeUtils.stringSize((String) current);
+                continue;
+            }
+            bytes += JvmSizeUtils.instanceSize(type);
+            if (jdkType && current instanceof Map) {
+                Map<?, ?> map = (Map<?, ?>) current;
+                bytes += JvmSizeUtils.objectArraySize(tableLength(map.size()));
+                for (Map.Entry<?, ?> entry : map.entrySet()) {
+                    bytes += HASH_MAP_NODE_BYTES;
+                    enqueue(entry.getKey(), seen, pending);
+                    enqueue(entry.getValue(), seen, pending);
+                }
+            } else if (jdkType && current instanceof Collection) {
+                Collection<?> collection = (Collection<?>) current;
+                bytes += JvmSizeUtils.objectArraySize(collection.size());
+                for (Object element : collection) {
+                    enqueue(element, seen, pending);
+                }
+            } else {
+                for (Field field : referenceFields(type)) {
+                    try {
+                        field.setAccessible(true);
+                        enqueue(field.get(current), seen, pending);
+                    } catch (IllegalAccessException | RuntimeException 
ignored) {
+                        // Inaccessible JDK state counts as its shallow size; 
the acceptance band absorbs it.
+                    }
+                }
+            }
+        }
+        return bytes;
+    }
+
+    private static void enqueue(Object value, IdentityHashMap<Object, Boolean> 
seen, Deque<Object> pending) {
+        if (value == null || isSharedLeaf(value)) {
+            return;
+        }
+        if (seen.put(value, Boolean.TRUE) == null) {
+            pending.add(value);
+        }
+    }
+
+    private static boolean isSharedLeaf(Object value) {
+        return value instanceof Class || value instanceof ClassLoader
+                || value instanceof Thread || value instanceof Enum
+                || value instanceof java.lang.ref.Reference;
+    }
+
+    private static List<Field> referenceFields(Class<?> type) {
+        List<Field> cached = REFERENCE_FIELD_CACHE.get(type);
+        if (cached != null) {
+            return cached;
+        }
+        List<Field> fields = new ArrayList<>();
+        for (Class<?> owner = type; owner != null && owner != Object.class; 
owner = owner.getSuperclass()) {
+            for (Field field : owner.getDeclaredFields()) {
+                if (!Modifier.isStatic(field.getModifiers()) && 
!field.getType().isPrimitive()) {
+                    fields.add(field);
+                }
+            }
+        }
+        REFERENCE_FIELD_CACHE.put(type, fields);
+        return fields;
+    }
+
+    private static int tableLength(int size) {
+        int length = 1;
+        while (length * 3L < size * 4L) {
+            length <<= 1;
+        }
+        return length;
+    }
+
+    private static long hashMapNodeBytes() {
+        try {
+            return 
JvmSizeUtils.instanceSize(Class.forName("java.util.HashMap$Node"));
+        } catch (ClassNotFoundException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCacheSizeEstimatorTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCacheSizeEstimatorTest.java
new file mode 100644
index 00000000000..3ba694bc7c1
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCacheSizeEstimatorTest.java
@@ -0,0 +1,351 @@
+// 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.MetaCacheSizeEstimate;
+
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.ResolvingFileIO;
+import org.apache.paimon.fs.hadoop.HadoopFileIO;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.rest.RESTToken;
+import org.apache.paimon.rest.RESTTokenFileIO;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.CatalogEnvironment;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+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 org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.MapType;
+import org.apache.paimon.types.MultisetType;
+import org.apache.paimon.types.RowType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class PaimonCacheSizeEstimatorTest {
+    private static final Identifier TABLE = Identifier.create("db", "t");
+
+    @TempDir
+    java.nio.file.Path warehouse;
+
+    @Test
+    void admissionWeightCoversTheStoreAndIndexesBuiltAfterAdmission() throws 
Exception {
+        // A cached table is weighed once, but the first snapshot lookup or 
scan builds its store (RowType copies
+        // of the schema, a copy of the options, a partial-update table's 
merge function) and field lookups build
+        // every RowType's lazy index maps. The admission weight must already 
cover that grown graph without the
+        // FileIO allowance, which pays for the FileIO. MUTATION: dropping the 
store-growth reservation -> every
+        // fixture's grown graph exceeds its weight -> red; dropping the 
partial-update reservation -> the wide
+        // partial-update fixture does -> red.
+        List<FileStoreTable> fixtures = new ArrayList<>();
+        fixtures.add(table("append_narrow", 10, 0, 0, 0, null));
+        fixtures.add(table("append_wide", 300, 0, 0, 0, null));
+        fixtures.add(table("pk_narrow", 10, 2, 0, 0, null));
+        fixtures.add(table("pk_wide_options", 300, 2, 50, 0, null));
+        fixtures.add(table("pk_many_keys", 64, 30, 8, 0, null));
+        fixtures.add(table("options", 10, 0, 100, 0, null));
+        fixtures.add(table("nested_narrow", 0, 0, 0, 1, null));
+        fixtures.add(table("nested_wide", 0, 0, 0, 200, null));
+        fixtures.add(table("array_of_rows", 0, 0, 0, 0, new 
ArrayType(rowOf(20))));
+        fixtures.add(table("map_of_rows", 0, 0, 0, 0, new 
MapType(DataTypes.INT(), rowOf(20))));
+        fixtures.add(table("multiset", 0, 0, 0, 0, new 
MultisetType(DataTypes.STRING())));
+        fixtures.add(mergeEngineTable("partial_update_wide", 300, 
"partial-update"));
+        fixtures.add(mergeEngineTable("aggregation_wide", 300, "aggregation"));
+        for (FileStoreTable table : fixtures) {
+            long weight = weight(table) - 
PaimonCacheSizeEstimator.estimateFileIO(table.fileIO());
+            Assertions.assertNull(readField(table, table.getClass(), 
"lazyStore"),
+                    table.name() + ": estimation must not build the store");
+            grow(table);
+            long grown = EstimatorCalibrationAssertions.graphSize(table);
+            Assertions.assertTrue(weight >= grown,
+                    table.name() + ": weight " + weight + " does not cover the 
grown graph " + grown);
+        }
+    }
+
+    @Test
+    void storeGrowthReservationScalesWithTheSchema() throws Exception {
+        assertGrownDelta("fields", table("fields_narrow", 10, 0, 0, 0, null),
+                table("fields_wide", 300, 0, 0, 0, null));
+        assertGrownDelta("primary-key fields", table("pk_fields_narrow", 10, 
2, 0, 0, null),
+                table("pk_fields_wide", 300, 2, 0, 0, null));
+        assertGrownDelta("options", table("options_none", 10, 0, 0, 0, null),
+                table("options_many", 10, 0, 100, 0, null));
+        assertGrownDelta("nested fields", table("nested_one", 0, 0, 0, 1, 
null),
+                table("nested_many", 0, 0, 0, 200, null));
+    }
+
+    @Test
+    void everyTableCarriesAnAllowanceForItsFileIo() throws Exception {
+        // A REST catalog gives each table its own RESTTokenFileIO or 
ResolvingFileIO and a Hive catalog a FileIO
+        // per table location; all of them grow after admission, and none can 
be told apart from a FileIO the
+        // catalog shares. So a table's weight carries the same allowance 
whatever its FileIO is. MUTATION:
+        // charging only RESTTokenFileIO -> the other tables weigh a whole 
allowance less -> red.
+        FileStoreTable local = table("file_io", 10, 0, 0, 0, null);
+        CatalogContext context = CatalogContext.create(new Options());
+        ResolvingFileIO resolving = resolvingFileIO(context);
+        HadoopFileIO hadoop = new HadoopFileIO(local.location());
+        hadoop.configure(context);
+        List<FileIO> fileIOs = Arrays.asList(local.fileIO(), resolving, hadoop,
+                new RESTTokenFileIO(context, null, TABLE, local.location()));
+        long withoutFileIO = weight(local) - 
PaimonCacheSizeEstimator.estimateFileIO(local.fileIO());
+        for (FileIO fileIO : fileIOs) {
+            FileStoreTable table = FileStoreTableFactory.create(
+                    fileIO, local.location(), local.schema(), 
CatalogEnvironment.empty());
+            long allowance = PaimonCacheSizeEstimator.estimateFileIO(fileIO);
+            Assertions.assertTrue(allowance >= 16L * 1024L);
+            Assertions.assertEquals(withoutFileIO + allowance, weight(table),
+                    fileIO.getClass().getSimpleName() + " must be charged to 
its table");
+        }
+
+        // A ResolvingFileIO builds its backend on the first data access, 
after admission.
+        resolving.exists(local.location());
+        long resolved = EstimatorCalibrationAssertions.graphSize(resolving)
+                - EstimatorCalibrationAssertions.graphSize(context);
+        
Assertions.assertTrue(PaimonCacheSizeEstimator.estimateFileIO(resolving) >= 
resolved,
+                "the allowance does not cover a ResolvingFileIO with its 
backend: " + resolved);
+    }
+
+    @Test
+    void tablesOutsideTheFileStoreCarryTheFileIoAllowanceToo() throws 
Exception {
+        // A REST catalog builds an external table of any kind on a 
ResolvingFileIO of its own, even with data
+        // tokens enabled, so format, object, Lance and Iceberg tables grow 
after admission like a FileStoreTable.
+        // MUTATION: charging the FileIO only on the FileStoreTable path -> 
each weight falls below the allowance
+        // alone -> red.
+        CatalogContext context = CatalogContext.create(new Options());
+        String location = warehouse.resolve("external").toUri().toString();
+        List<Table> tables = Arrays.asList(
+                
FormatTable.builder().fileIO(resolvingFileIO(context)).identifier(TABLE).rowType(rowOf(10))
+                        
.partitionKeys(Collections.emptyList()).location(location).format(FormatTable.Format.CSV)
+                        .options(Collections.emptyMap()).build(),
+                
ObjectTable.builder().fileIO(resolvingFileIO(context)).identifier(TABLE).location(location)
+                        .options(Collections.emptyMap()).build(),
+                
LanceTable.builder().fileIO(resolvingFileIO(context)).identifier(TABLE).rowType(rowOf(10))
+                        
.location(location).options(Collections.emptyMap()).build(),
+                
IcebergTable.builder().fileIO(resolvingFileIO(context)).identifier(TABLE).rowType(rowOf(10))
+                        
.partitionKeys(Collections.emptyList()).location(location)
+                        .options(Collections.emptyMap()).build());
+        for (Table table : tables) {
+            String kind = table.getClass().getSimpleName();
+            long weight = weight(table);
+            long allowance = 
PaimonCacheSizeEstimator.estimateFileIO(table.fileIO());
+            Assertions.assertTrue(weight > allowance,
+                    kind + ": weight " + weight + " leaves out the FileIO 
allowance " + allowance);
+            // A ResolvingFileIO builds its backend on the first data access, 
after admission.
+            table.fileIO().exists(new Path(location));
+            long grown = EstimatorCalibrationAssertions.graphSize(table)
+                    - EstimatorCalibrationAssertions.graphSize(context);
+            Assertions.assertTrue(weight >= grown,
+                    kind + ": weight " + weight + " does not cover the grown 
graph " + grown);
+        }
+    }
+
+    @Test
+    void fileIoAllowanceCoversARestDataTokenAndItsRefresh() throws Exception {
+        // The token arrives after admission and a refresh replaces it, so the 
one allowance must cover the token
+        // the first data access fetches as well as a later, larger one. The 
1,200-character security token is the
+        // usual STS size; the 8,192-character one stands for a credential 
several times larger.
+        // MUTATION: a 4 KB allowance -> the refreshed token outgrows it -> 
red.
+        FileStoreTable local = table("rest_token", 10, 0, 0, 0, null);
+        CatalogContext context = CatalogContext.create(new Options());
+        RESTTokenFileIO fileIO = new RESTTokenFileIO(context, null, TABLE, 
local.location());
+        long allowance = PaimonCacheSizeEstimator.estimateFileIO(fileIO);
+        for (int securityTokenChars : new int[] {1200, 8192}) {
+            Map<String, String> token = new HashMap<>();
+            token.put("fs.oss.accessKeyId", "STS." + "a".repeat(28));
+            token.put("fs.oss.accessKeySecret", "b".repeat(44));
+            token.put("fs.oss.securityToken", "c".repeat(securityTokenChars));
+            token.put("fs.oss.endpoint", 
"oss-cn-hangzhou-internal.aliyuncs.com");
+            setToken(fileIO, new RESTToken(token, System.currentTimeMillis() + 
3_600_000L));
+            long owned = EstimatorCalibrationAssertions.graphSize(fileIO)
+                    - EstimatorCalibrationAssertions.graphSize(context);
+            Assertions.assertTrue(allowance >= owned, "allowance " + allowance
+                    + " does not cover the table-owned graph with a " + 
securityTokenChars + "-character token "
+                    + owned);
+        }
+    }
+
+    /**
+     * A primary-key table with {@code fieldCount} INT value fields under 
{@code mergeEngine}: a partial-update
+     * table puts them all in one sequence group with a default aggregate 
function, an aggregation table sums them.
+     */
+    private FileStoreTable mergeEngineTable(String name, int fieldCount, 
String mergeEngine) throws Exception {
+        Schema.Builder builder = Schema.newBuilder()
+                .column("part", DataTypes.INT())
+                .column("key_0", DataTypes.INT().notNull())
+                .column("seq_0", DataTypes.INT());
+        List<String> grouped = new ArrayList<>();
+        for (int i = 0; i < fieldCount; i++) {
+            builder.column("field_" + i, DataTypes.INT());
+            grouped.add("field_" + i);
+        }
+        builder.primaryKey("key_0", 
"part").partitionKeys("part").option("merge-engine", mergeEngine);
+        if ("partial-update".equals(mergeEngine)) {
+            builder.option("fields.seq_0.sequence-group", String.join(",", 
grouped));
+            builder.option("fields.default-aggregate-function", 
"last_non_null_value");
+        } else if ("aggregation".equals(mergeEngine)) {
+            builder.option("fields.default-aggregate-function", "sum");
+        }
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path path = new Path(warehouse.resolve(name).toUri());
+        new SchemaManager(fileIO, path).createTable(builder.build());
+        return FileStoreTableFactory.create(fileIO, path);
+    }
+
+    private static ResolvingFileIO resolvingFileIO(CatalogContext context) {
+        ResolvingFileIO fileIO = new ResolvingFileIO();
+        fileIO.configure(context);
+        return fileIO;
+    }
+
+    private static void setToken(RESTTokenFileIO fileIO, RESTToken token) 
throws ReflectiveOperationException {
+        Field tokenField = RESTTokenFileIO.class.getDeclaredField("token");
+        tokenField.setAccessible(true);
+        tokenField.set(fileIO, token);
+    }
+
+    private void assertGrownDelta(String fixture, FileStoreTable small, 
FileStoreTable large) throws Exception {
+        long smallWeight = weight(small);
+        long largeWeight = weight(large);
+        grow(small);
+        grow(large);
+        EstimatorCalibrationAssertions.assertConservativeDelta(fixture, 
smallWeight, largeWeight, small, large);
+    }
+
+    private static long weight(Table table) {
+        MetaCacheSizeEstimate estimate = 
PaimonCacheSizeEstimator.estimateTable(
+                TABLE, table, 
PaimonMetaCacheCatalog.TABLE_ENTRY_OVERHEAD_BYTES);
+        Assertions.assertTrue(estimate.isComplete(), 
estimate.getIncompleteReason());
+        return estimate.getBytes();
+    }
+
+    /**
+     * A table with a partition column, {@code fieldCount} INT fields, {@code 
primaryKeyCount} primary key fields
+     * (besides the partition column), {@code optionCount} options, an 
optional ROW of {@code nestedFieldCount}
+     * fields and an optional {@code extraType} column.
+     */
+    private FileStoreTable table(String name, int fieldCount, int 
primaryKeyCount, int optionCount,
+            int nestedFieldCount, DataType extraType) throws Exception {
+        Schema.Builder builder = Schema.newBuilder().column("part", 
DataTypes.INT());
+        for (int i = 0; i < fieldCount; i++) {
+            builder.column("field_" + i, DataTypes.INT());
+        }
+        List<String> primaryKeys = new ArrayList<>();
+        for (int i = 0; i < primaryKeyCount; i++) {
+            builder.column("key_" + i, DataTypes.INT().notNull());
+            primaryKeys.add("key_" + i);
+        }
+        if (nestedFieldCount > 0) {
+            builder.column("payload", rowOf(nestedFieldCount));
+        }
+        if (extraType != null) {
+            builder.column("extra", extraType);
+        }
+        if (!primaryKeys.isEmpty()) {
+            primaryKeys.add("part");
+            builder.primaryKey(primaryKeys);
+        }
+        builder.partitionKeys("part");
+        for (int i = 0; i < optionCount; i++) {
+            builder.option("option_" + i, "value_" + i);
+        }
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path path = new Path(warehouse.resolve(name).toUri());
+        new SchemaManager(fileIO, path).createTable(builder.build());
+        return FileStoreTableFactory.create(fileIO, path);
+    }
+
+    private static RowType rowOf(int fieldCount) {
+        List<DataField> fields = new ArrayList<>();
+        for (int i = 0; i < fieldCount; i++) {
+            fields.add(new DataField(1000 + i, "nested_" + i, 
DataTypes.INT()));
+        }
+        return new RowType(fields);
+    }
+
+    /** What query planning builds on a cached table: the store, then every 
reachable RowType's index maps. */
+    private static void grow(FileStoreTable table) throws Exception {
+        table.latestSnapshot();
+        table.newReadBuilder().newScan();
+        Object store = readField(table, table.getClass(), "lazyStore");
+        for (Class<?> owner = store.getClass(); owner != null && owner != 
Object.class;
+                owner = owner.getSuperclass()) {
+            for (Field field : owner.getDeclaredFields()) {
+                if (RowType.class.isAssignableFrom(field.getType())) {
+                    field.setAccessible(true);
+                    Object rowType = field.get(store);
+                    if (rowType != null) {
+                        buildIndexes((RowType) rowType);
+                    }
+                }
+            }
+        }
+        for (DataField field : table.schema().fields()) {
+            buildIndexes(field.type());
+        }
+    }
+
+    private static void buildIndexes(DataType type) {
+        if (type instanceof RowType) {
+            RowType rowType = (RowType) type;
+            // The store also holds empty row types (for example the bucket 
key type of an unbucketed table).
+            if (!rowType.getFields().isEmpty()) {
+                DataField first = rowType.getFields().get(0);
+                rowType.getField(first.name());
+                rowType.getFieldIndex(first.name());
+                rowType.getField(first.id());
+                rowType.getFieldIndexByFieldId(first.id());
+            }
+            for (DataField field : rowType.getFields()) {
+                buildIndexes(field.type());
+            }
+        } else if (type instanceof ArrayType) {
+            buildIndexes(((ArrayType) type).getElementType());
+        } else if (type instanceof MultisetType) {
+            buildIndexes(((MultisetType) type).getElementType());
+        } else if (type instanceof MapType) {
+            buildIndexes(((MapType) type).getKeyType());
+            buildIndexes(((MapType) type).getValueType());
+        }
+    }
+
+    private static Object readField(Object target, Class<?> owner, String 
fieldName) throws Exception {
+        Field field = owner.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        return field.get(target);
+    }
+}


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

Reply via email to