github-actions[bot] commented on code in PR #66717:
URL: https://github.com/apache/doris/pull/66717#discussion_r3800214550


##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalMetaCache.java:
##########
@@ -86,41 +111,164 @@ public Table getPaimonTable(NameMapping nameMapping) {
 
     public PaimonSnapshotCacheValue getSnapshotCache(ExternalTable dorisTable) 
{
         NameMapping nameMapping = dorisTable.getOrBuildNameMapping();
-        return 
tableEntry.get(nameMapping.getCtlId()).get(nameMapping).getLatestSnapshotCacheValue();
+        MetaCacheEntry<NameMapping, PaimonTableCacheValue> tables = 
tableEntry.get(nameMapping.getCtlId());
+        PaimonTableCacheValue tableValue = tables.get(nameMapping);
+        PaimonSnapshot fence = loadLatestSnapshotFence(nameMapping, 
tableValue.getPaimonTable()).getSnapshot();
+        if (!tables.isEffectivelyEnabled()) {
+            // Projections are keyed by the synthetic generation of a 
published table handle. An
+            // ineffective table entry publishes nothing, so nothing keyed by 
this load could ever
+            // be looked up again: serve it directly instead of churning the 
snapshot entry.
+            return executeAuthenticated(nameMapping,
+                    () -> latestSnapshotProjectionLoader.loadAtFence(
+                            nameMapping, fence, tableValue.getGeneration()));
+        }
+        // Order fence observations, not snapshot ids: a rollback moves the 
latest snapshot
+        // backwards, and a concurrent call may finish after a later 
observation (reversed
+        // completion). Either way the most recently observed fence is the one 
future lookups read.
+        long observation = fenceObservations.incrementAndGet();
+        PaimonSnapshotEntryKey key = PaimonSnapshotEntryKey.of(
+                nameMapping, fence, tableValue.getGeneration());
+        MetaCacheEntry<PaimonSnapshotEntryKey, PaimonSnapshotCacheValue> entry 
=
+                snapshotEntry.get(nameMapping.getCtlId());
+        AtomicBoolean loaded = new AtomicBoolean();
+        PaimonSnapshotCacheValue snapshotValue = entry.get(key,
+                ignored -> executeAuthenticated(nameMapping, () -> {
+                    loaded.set(true);
+                    return latestSnapshotProjectionLoader.loadAtFence(
+                            nameMapping, fence, tableValue.getGeneration());
+                }));
+        LatestFenceOwner owner = new LatestFenceOwner(nameMapping, 
tableValue.getGeneration());
+        ObservedFence latest = latestObservedFences.compute(owner, (ignored, 
current) ->

Review Comment:
   [P2] Remove owners for generations that were never published
   
   `tables.get()` still returns the loaded table when weighted admission 
rejects it, and every such load has a fresh synthetic generation. This compute 
therefore retains a distinct owner on each lookup; the later current-generation 
check only invalidates the snapshot child, while a rejected generation gets 
neither a replacement nor a removal callback that could retire the owner. The 
same ordering can resurrect an old owner when a blocked load resumes after 
replacement or catalog invalidation already performed cleanup. Persistently 
oversized or unsupported tables can thus grow `latestObservedFences` without 
any cache/budget bound. Please conditionally remove the published `(owner, 
latest)` when the generation is not current, and cover both repeated rejection 
and a delayed old-generation load with an owner-cardinality assertion.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/AbstractExternalMetaCache.java:
##########
@@ -124,10 +228,10 @@ public <K, V> MetaCacheEntry<K, V> entry(long catalogId, 
String entryName, Class
     }
 
     @Override
-    public void invalidateCatalog(long catalogId) {
+    public synchronized void invalidateCatalog(long catalogId) {

Review Comment:
   [P2] Do not close a catalog while holding shared lifecycle locks
   
   `removed.close()` synchronously invalidates every entry and releases every 
reservation in the catalog, but it runs under both the engine-wide 
`AbstractExternalMetaCache` monitor and the manager's lifecycle stripe. There 
are only 64 stripes, so dropping or changing one large catalog blocks first 
use/reinitialization of every catalog for that engine and also unrelated, 
possibly different-engine catalogs whose IDs collide on the stripe. Please 
replace the shared stripe with genuinely per-catalog lifecycle state, detach 
under the engine monitor, then close after releasing the engine-global lock 
while retaining only that catalog's fence; alternatively make budget ownership 
generation-aware before allowing old/new groups to coexist. A test can block 
one close and verify progress for a second same-engine ID and a 
stripe-colliding ID.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java:
##########
@@ -186,13 +188,19 @@ public static PaimonPartitionInfo 
generatePartitionInfo(Table table, List<Column
 
             List<String> partitionValues = 
Lists.newArrayListWithExpectedSize(partitionColumns.size());
             LinkedHashMap<String, String> orderedTypedSpec = new 
LinkedHashMap<>();
+            retainedPayloadBytes = 
MetaCacheWeightUtils.saturatedAdd(retainedPayloadBytes,
+                    
PaimonPartitionInfo.partitionColumnBytes(partitionColumns.size()));
             for (Column partitionColumn : partitionColumns) {
                 String partitionColumnName = partitionColumn.getName();
                 
Preconditions.checkState(typedSpec.containsKey(partitionColumnName),
                         "Partition column not found in Paimon typed spec: " + 
partitionColumnName);
                 String partitionValue = typedSpec.get(partitionColumnName);
                 partitionValues.add(partitionValue);
                 orderedTypedSpec.put(partitionColumnName, partitionValue);
+                retainedPayloadBytes = 
PaimonPartitionInfo.addRetainedStringPayload(

Review Comment:
   [P2] Count shared partition-column names once
   
   `partitionColumnName` is the stable string held by the schema `Column`, and 
that same reference is inserted into every partition's `orderedTypedSpec`. 
Accumulating its full string payload inside the outer partition loop therefore 
charges N copies even though the retained graph has one string plus N map 
references (whose structural cost is already charged separately). Large 
partition sets can exceed an entry/catalog limit solely because of this 
overcount and remain permanently cold. The current JOL/benchmark fixtures hide 
the mismatch by constructing fresh field-name strings per partition. Please 
charge these schema names once outside the loop (while retaining per-partition 
entry costs) and calibrate with shared production-style name identities.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalMetaCacheBudgetManager.java:
##########
@@ -0,0 +1,587 @@
+// 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.datasource.metacache;
+
+import org.apache.doris.common.Config;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.LongUnaryOperator;
+import java.util.stream.Collectors;
+
+/**
+ * FE-wide admission accounting for managed external metadata caches.
+ *
+ * <p>All changes are serialized by one short critical section. Cache loads and
+ * estimators run outside it, so the lock only protects a few arithmetic and 
map
+ * operations while making global/catalog/entry reservation atomic.
+ */
+public final class ExternalMetaCacheBudgetManager {
+    private static final Logger LOG = 
LogManager.getLogger(ExternalMetaCacheBudgetManager.class);
+    private static final ExecutorService PEER_RECLAIM_EXECUTOR = 
Executors.newSingleThreadExecutor(runnable -> {
+        Thread thread = new Thread(runnable, 
"external-meta-cache-peer-reclaim");
+        thread.setDaemon(true);
+        return thread;
+    });
+
+    public static final String CATALOG_MAX_WEIGHT_PROPERTY = 
"meta.cache.max-weight";
+
+    private final Object lock = new Object();
+    private final OptionalLong globalMaxWeight;
+    private final Map<Long, Bucket> catalogBuckets = new HashMap<>();
+    private final Map<EntryScope, Bucket> entryBuckets = new HashMap<>();
+    private final Map<EntryScope, EntryBudget> entryBudgets = new HashMap<>();
+    private long globalUsedWeight;
+    private final AtomicLong globalRejectedCount = new AtomicLong();
+
+    public ExternalMetaCacheBudgetManager(OptionalLong globalMaxWeight) {
+        this.globalMaxWeight = Objects.requireNonNull(globalMaxWeight, 
"globalMaxWeight");
+        if (globalMaxWeight.isPresent() && globalMaxWeight.getAsLong() <= 0) {
+            throw new IllegalArgumentException("global max weight must be 
positive when enabled");
+        }
+    }
+
+    public static ExternalMetaCacheBudgetManager fromConfig() {
+        String configured = Config.external_meta_cache_max_weight;
+        long parsed = CacheSpec.parseWeight(
+                configured,
+                "external_meta_cache_max_weight",
+                true,
+                Runtime.getRuntime().maxMemory());
+        if (configured.trim().endsWith("%") && parsed == 0L) {
+            throw new IllegalArgumentException(
+                    "external_meta_cache_max_weight percentage must be greater 
than 0%");
+        }
+        return new ExternalMetaCacheBudgetManager(parsed == 0L ? 
OptionalLong.empty() : OptionalLong.of(parsed));
+    }
+
+    public OptionalLong parseCatalogMaxWeight(Map<String, String> 
catalogProperties) {
+        String configured = catalogProperties.get(CATALOG_MAX_WEIGHT_PROPERTY);
+        if (configured == null) {
+            return OptionalLong.empty();
+        }
+        long parsed = CacheSpec.parseWeight(configured, 
CATALOG_MAX_WEIGHT_PROPERTY, false, 0L);
+        if (parsed <= 0) {
+            throw new IllegalArgumentException(CATALOG_MAX_WEIGHT_PROPERTY + " 
must be positive");
+        }
+        return OptionalLong.of(parsed);
+    }
+
+    /** Validate a catalog limit at DDL time against this FE's configured 
global bound. */
+    public OptionalLong validateCatalogMaxWeight(Map<String, String> 
catalogProperties) {
+        OptionalLong catalogMaxWeight = 
parseCatalogMaxWeight(catalogProperties);
+        validateHierarchy(catalogMaxWeight, OptionalLong.empty());
+        return catalogMaxWeight;
+    }
+
+    /**
+     * Create the budget handle used by one physical per-catalog cache entry.
+     */
+    public EntryBudget createEntryBudget(long catalogId, String engine, String 
entryName,
+            OptionalLong catalogMaxWeight, OptionalLong entryMaxWeight) {
+        Objects.requireNonNull(engine, "engine");
+        Objects.requireNonNull(entryName, "entryName");
+        Objects.requireNonNull(catalogMaxWeight, "catalogMaxWeight");
+        Objects.requireNonNull(entryMaxWeight, "entryMaxWeight");
+        validateCatalogEntryHierarchy(catalogMaxWeight, entryMaxWeight);
+
+        OptionalLong effectiveMax = minimumPresent(globalMaxWeight, 
catalogMaxWeight, entryMaxWeight);
+        if (!effectiveMax.isPresent()) {
+            throw new IllegalArgumentException("entry budget requires at least 
one configured weight bound");
+        }
+
+        EntryScope scope = new EntryScope(catalogId, engine, entryName);
+        synchronized (lock) {
+            Bucket catalogBucket = catalogBuckets.get(catalogId);
+            long catalogLimit = minimumLimit(globalMaxWeight, 
catalogMaxWeight);
+            if (catalogBucket == null) {
+                catalogBucket = new Bucket(catalogLimit);
+                catalogBuckets.put(catalogId, catalogBucket);
+            } else if (catalogBucket.maxWeight != catalogLimit) {
+                throw new IllegalStateException("Conflicting catalog cache max 
weight for catalog " + catalogId);
+            }
+
+            if (entryBuckets.containsKey(scope)) {
+                throw new IllegalStateException("Duplicated external meta 
cache budget: " + scope);
+            }
+            Bucket entryBucket = new Bucket(effectiveMax.getAsLong());
+            EntryBudget entryBudget = new EntryBudget(
+                    this, scope, catalogBucket, entryBucket, 
effectiveMax.getAsLong());
+            entryBuckets.put(scope, entryBucket);
+            entryBudgets.put(scope, entryBudget);
+            return entryBudget;
+        }
+    }
+
+    public OptionalLong getGlobalMaxWeight() {
+        return globalMaxWeight;
+    }
+
+    public long getGlobalUsedWeight() {
+        synchronized (lock) {
+            return globalUsedWeight;
+        }
+    }
+
+    public long getGlobalRejectedCount() {
+        return globalRejectedCount.get();
+    }
+
+    public void validateHierarchy(OptionalLong catalogMaxWeight, OptionalLong 
entryMaxWeight) {
+        if (globalMaxWeight.isPresent() && catalogMaxWeight.isPresent()
+                && catalogMaxWeight.getAsLong() > globalMaxWeight.getAsLong()) 
{
+            throw new IllegalArgumentException(CATALOG_MAX_WEIGHT_PROPERTY + " 
can not exceed FE global max weight");
+        }
+        OptionalLong parent = catalogMaxWeight.isPresent() ? catalogMaxWeight 
: globalMaxWeight;
+        if (parent.isPresent() && entryMaxWeight.isPresent()
+                && entryMaxWeight.getAsLong() > parent.getAsLong()) {
+            throw new IllegalArgumentException("entry max weight can not 
exceed its parent max weight");
+        }
+    }
+
+    /**
+     * Validate persisted catalog-to-entry hierarchy without comparing it with 
this FE's local
+     * global bound. Catalog properties are validated on the master, while the 
global percentage
+     * is resolved independently from each FE's heap. Runtime admission 
therefore clamps to the
+     * local global limit instead of rejecting a catalog accepted on a larger 
master.
+     */
+    public void validateCatalogEntryHierarchy(OptionalLong catalogMaxWeight, 
OptionalLong entryMaxWeight) {
+        OptionalLong parent = catalogMaxWeight;
+        if (parent.isPresent() && entryMaxWeight.isPresent()
+                && entryMaxWeight.getAsLong() > parent.getAsLong()) {
+            throw new IllegalArgumentException("entry max weight can not 
exceed its parent max weight");
+        }
+    }
+
+    private Optional<AdmissionReservation> tryReserve(EntryBudget entryBudget, 
long bytes) {
+        checkWeight(bytes);
+        synchronized (lock) {
+            if (entryBudget.closed) {
+                return Optional.empty();
+            }
+            if (!fits(limitOf(globalMaxWeight), globalUsedWeight, bytes)
+                    || !fits(entryBudget.catalogBucket.maxWeight, 
entryBudget.catalogBucket.usedWeight, bytes)
+                    || !fits(entryBudget.entryBucket.maxWeight, 
entryBudget.entryBucket.usedWeight, bytes)) {
+                entryBudget.rejectedCount.incrementAndGet();
+                globalRejectedCount.incrementAndGet();
+                return Optional.empty();
+            }
+            addUsed(entryBudget, bytes);
+            return Optional.of(new AdmissionReservation(this, entryBudget, 
bytes));
+        }
+    }
+
+    private boolean resize(AdmissionReservation reservation, long newBytes) {
+        checkWeight(newBytes);
+        synchronized (lock) {
+            if (!reservation.active || reservation.entryBudget.closed) {
+                return false;
+            }
+            long delta = newBytes - reservation.bytes;
+            if (delta > 0 && (!fits(limitOf(globalMaxWeight), 
globalUsedWeight, delta)
+                    || !fits(reservation.entryBudget.catalogBucket.maxWeight,
+                            reservation.entryBudget.catalogBucket.usedWeight, 
delta)
+                    || !fits(reservation.entryBudget.entryBucket.maxWeight,
+                            reservation.entryBudget.entryBucket.usedWeight, 
delta))) {
+                reservation.entryBudget.rejectedCount.incrementAndGet();
+                globalRejectedCount.incrementAndGet();
+                return false;
+            }
+            if (delta >= 0) {
+                addUsed(reservation.entryBudget, delta);
+            } else {
+                subtractUsed(reservation.entryBudget, -delta);
+            }
+            reservation.bytes = newBytes;
+            return true;
+        }
+    }
+
+    private void release(AdmissionReservation reservation) {
+        synchronized (lock) {
+            if (!reservation.active) {
+                return;
+            }
+            if (reservation.entryBudget.closed) {
+                reservation.bytes = 0L;
+                reservation.active = false;
+                return;
+            }
+            subtractUsed(reservation.entryBudget, reservation.bytes);
+            reservation.bytes = 0L;
+            reservation.active = false;
+        }
+    }
+
+    private void close(EntryBudget entryBudget) {
+        synchronized (lock) {
+            if (entryBudget.closed) {
+                return;
+            }
+            if (entryBudget.entryBucket.usedWeight != 0L) {
+                long leakedWeight = entryBudget.entryBucket.usedWeight;
+                LOG.error("Force-closing external metadata cache budget {} 
with {} bytes still reserved",
+                        entryBudget.scope, leakedWeight);
+                if (leakedWeight <= globalUsedWeight
+                        && leakedWeight <= 
entryBudget.catalogBucket.usedWeight) {
+                    globalUsedWeight -= leakedWeight;
+                    entryBudget.catalogBucket.usedWeight -= leakedWeight;
+                    entryBudget.entryBucket.usedWeight = 0L;
+                } else {
+                    LOG.error("External metadata cache accounting is 
inconsistent while closing {}; "
+                                    + "globalUsed={}, catalogUsed={}, 
entryUsed={}",
+                            entryBudget.scope, globalUsedWeight,
+                            entryBudget.catalogBucket.usedWeight, 
leakedWeight);
+                    globalUsedWeight = Math.max(0L, globalUsedWeight - 
leakedWeight);
+                    entryBudget.catalogBucket.usedWeight = Math.max(
+                            0L, entryBudget.catalogBucket.usedWeight - 
leakedWeight);
+                    entryBudget.entryBucket.usedWeight = 0L;
+                }
+            }
+            entryBudget.closed = true;
+            entryBudget.reclaimer = null;
+            entryBuckets.remove(entryBudget.scope, entryBudget.entryBucket);
+            entryBudgets.remove(entryBudget.scope, entryBudget);
+            Bucket catalogBucket = entryBudget.catalogBucket;
+            boolean catalogStillReferenced = entryBuckets.keySet().stream()
+                    .anyMatch(scope -> scope.catalogId == 
entryBudget.scope.catalogId);
+            if (!catalogStillReferenced && catalogBucket.usedWeight == 0L) {
+                catalogBuckets.remove(entryBudget.scope.catalogId, 
catalogBucket);
+            }
+        }
+    }
+
+    private void addUsed(EntryBudget entryBudget, long bytes) {
+        globalUsedWeight += bytes;
+        entryBudget.catalogBucket.usedWeight += bytes;
+        entryBudget.entryBucket.usedWeight += bytes;
+    }
+
+    private void subtractUsed(EntryBudget entryBudget, long bytes) {
+        if (bytes > globalUsedWeight
+                || bytes > entryBudget.catalogBucket.usedWeight
+                || bytes > entryBudget.entryBucket.usedWeight) {
+            throw new IllegalStateException("external meta cache budget 
accounting underflow");
+        }
+        globalUsedWeight -= bytes;
+        entryBudget.catalogBucket.usedWeight -= bytes;
+        entryBudget.entryBucket.usedWeight -= bytes;
+    }
+
+    private void requestPeerReclaim(EntryBudget requester, long 
additionalBytes) {
+        if (additionalBytes <= 0L || requester.closed) {
+            return;
+        }
+        long reclaimBytes;
+        synchronized (lock) {
+            if (requester.closed) {
+                return;
+            }
+            long globalDeficit = deficit(limitOf(globalMaxWeight), 
globalUsedWeight, additionalBytes);
+            long catalogDeficit = deficit(
+                    requester.catalogBucket.maxWeight, 
requester.catalogBucket.usedWeight, additionalBytes);
+            reclaimBytes = Math.max(globalDeficit, catalogDeficit);
+        }
+        if (reclaimBytes <= 0L) {
+            return;
+        }
+        // Rejected values are returned uncached; there is no queue of pending 
admissions to fund.
+        // Coalesce concurrent misses to the largest single admission instead 
of summing identical
+        // deficits and evicting an entire peer cache during a miss burst.
+        requester.requestedAdmissionBytes.accumulateAndGet(additionalBytes, 
Math::max);
+        schedulePeerReclaim(requester);
+    }
+
+    private void schedulePeerReclaim(EntryBudget requester) {
+        if (!requester.reclaimScheduled.compareAndSet(false, true)) {
+            return;
+        }
+        try {
+            PEER_RECLAIM_EXECUTOR.execute(() -> drainPeerReclaim(requester));
+        } catch (RejectedExecutionException e) {
+            requester.reclaimScheduled.set(false);
+            LOG.warn("Failed to schedule peer reclamation for external 
metadata cache budget {}",
+                    requester.scope, e);
+        }
+    }
+
+    private void drainPeerReclaim(EntryBudget requester) {
+        try {
+            long requestedAdmissionBytes = 
requester.requestedAdmissionBytes.getAndSet(0L);
+            if (requestedAdmissionBytes <= 0L || requester.closed) {
+                return;
+            }
+            List<EntryBudget> candidates;
+            synchronized (lock) {

Review Comment:
   [P2] Sort reclaim candidates outside the global budget lock
   
   `drainPeerReclaim` holds the one FE-wide accounting lock while filtering and 
sorting every active `EntryBudget`. That same lock gates every reserve, resize, 
release, budget create/close, and stats operation, so with many initialized 
catalogs a pressure-triggered reclaim becomes an O(E log E) global pause for 
unrelated cache traffic. Please snapshot the candidate/order fields under the 
lock, sort outside it, revalidate current/closed state, and recompute the 
current deficit before invoking each reclaimer. A many-budget concurrency test 
should pause candidate ordering and prove an unrelated reserve/release can 
still complete.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to