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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 37671319cde branch-4.2: [fix](metacache) Complete guarded external 
table lookup cleanup (#69806) (#70158)
37671319cde is described below

commit 37671319cdefc44dd8e5b85a0584a4826460f03d
Author: 924060929 <[email protected]>
AuthorDate: Wed Nov 4 14:22:24 2026 +0800

    branch-4.2: [fix](metacache) Complete guarded external table lookup cleanup 
(#69806) (#70158)
    
    cherry pick from #69806
---
 .../org/apache/doris/catalog/RefreshManager.java   | 369 +++++++++++----------
 .../apache/doris/datasource/ExternalCatalog.java   |  96 ++++--
 .../apache/doris/datasource/ExternalDatabase.java  |   9 +-
 .../datasource/ExternalLookupRefreshTest.java      | 206 ++++++++++++
 4 files changed, 482 insertions(+), 198 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java
index 76f6e207b98..70ac400cfe8 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java
@@ -95,13 +95,18 @@ public class RefreshManager {
         if (!(catalog instanceof ExternalCatalog)) {
             throw new DdlException("Only support refresh database in external 
catalog");
         }
-        DatabaseIf db = catalog.getDbOrDdlException(dbName);
-        // Local DB-object eviction also resets metadata; only an explicit 
refresh retires access.
-        invalidateLanceTableAccess((ExternalDatabase) db);
-        refreshDbInternal((ExternalDatabase) db);
+        ((ExternalCatalog) catalog).beginTableLookupRefresh();
+        try {
+            DatabaseIf db = catalog.getDbOrDdlException(dbName);
+            // Local DB-object eviction also resets metadata; only an explicit 
refresh retires access.
+            invalidateLanceTableAccess((ExternalDatabase) db);
+            refreshDbInternal((ExternalDatabase) db);
 
-        ExternalObjectLog log = 
ExternalObjectLog.createForRefreshDb(catalog.getId(), db.getFullName());
-        Env.getCurrentEnv().getEditLog().logRefreshExternalDb(log);
+            ExternalObjectLog log = 
ExternalObjectLog.createForRefreshDb(catalog.getId(), db.getFullName());
+            Env.getCurrentEnv().getEditLog().logRefreshExternalDb(log);
+        } finally {
+            ((ExternalCatalog) catalog).endTableLookupRefresh();
+        }
     }
 
     public void replayRefreshDb(ExternalObjectLog log) {
@@ -112,18 +117,23 @@ public class RefreshManager {
                 LOG.warn("failed to find catalog when replaying refresh db: 
{}", log.debugForRefreshDb());
                 return;
             }
-            invalidateLanceTableAccess(catalog, log);
-            Optional<ExternalDatabase<? extends ExternalTable>> db;
-            if (!Strings.isNullOrEmpty(log.getDbName())) {
-                db = catalog.getDbForReplay(log.getDbName());
-            } else {
-                db = catalog.getDbForReplay(log.getDbId());
-            }
+            catalog.beginTableLookupRefresh();
+            try {
+                invalidateLanceTableAccess(catalog, log);
+                Optional<ExternalDatabase<? extends ExternalTable>> db;
+                if (!Strings.isNullOrEmpty(log.getDbName())) {
+                    db = catalog.getDbForReplay(log.getDbName());
+                } else {
+                    db = catalog.getDbForReplay(log.getDbId());
+                }
 
-            if (!db.isPresent()) {
-                invalidateColdReplayDatabase(catalog, log);
-            } else {
-                refreshDbInternal(db.get());
+                if (!db.isPresent()) {
+                    invalidateColdReplayDatabase(catalog, log);
+                } else {
+                    refreshDbInternal(db.get());
+                }
+            } finally {
+                catalog.endTableLookupRefresh();
             }
         });
     }
@@ -173,42 +183,52 @@ public class RefreshManager {
             throw new DdlException("Only support refresh ExternalCatalog 
Tables");
         }
 
-        DatabaseIf db = catalog.getDbNullable(dbName);
-        if (db == null) {
-            if (!ignoreIfNotExists) {
-                throw new DdlException("Database " + dbName + " does not exist 
in catalog " + catalog.getName());
+        ((ExternalCatalog) catalog).beginTableLookupRefresh();
+        try {
+            DatabaseIf db = catalog.getDbNullable(dbName);
+            if (db == null) {
+                if (!ignoreIfNotExists) {
+                    throw new DdlException("Database " + dbName + " does not 
exist in catalog " + catalog.getName());
+                }
+                return;
             }
-            return;
-        }
 
-        TableIf table = db.getTableNullable(tableName);
-        if (table == null) {
-            if (!ignoreIfNotExists) {
-                throw new DdlException("Table " + tableName + " does not exist 
in db " + dbName);
+            TableIf table = db.getTableNullable(tableName);
+            if (table == null) {
+                if (!ignoreIfNotExists) {
+                    throw new DdlException("Table " + tableName + " does not 
exist in db " + dbName);
+                }
+                return;
             }
-            return;
+            long updateTime = System.currentTimeMillis();
+            refreshTableInternal((ExternalDatabase) db, (ExternalTable) table, 
updateTime, true);
+            ExternalObjectLog log = 
ExternalObjectLog.createForRefreshTable(catalog.getId(), db.getFullName(),
+                    table.getName(), updateTime, true);
+            Env.getCurrentEnv().getEditLog().logRefreshExternalTable(log);
+        } finally {
+            ((ExternalCatalog) catalog).endTableLookupRefresh();
         }
-        long updateTime = System.currentTimeMillis();
-        refreshTableInternal((ExternalDatabase) db, (ExternalTable) table, 
updateTime, true);
-        ExternalObjectLog log = 
ExternalObjectLog.createForRefreshTable(catalog.getId(), db.getFullName(),
-                table.getName(), updateTime, true);
-        Env.getCurrentEnv().getEditLog().logRefreshExternalTable(log);
     }
 
     public void refreshTableAfterCommit(ExternalTable table) {
-        long updateTime = System.currentTimeMillis();
+        ((ExternalCatalog) table.getCatalog()).beginTableLookupRefresh();
         try {
-            
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateRowCountCache(table);
-            refreshTableInternal((ExternalDatabase) table.getDatabase(), 
table, updateTime);
-        } catch (RuntimeException e) {
-            // The external transaction is already committed. Still notify 
follower FEs even if a
-            // cache layer failed, so peers do not keep serving the pre-commit 
state.
-            LOG.warn("Failed to refresh table cache after committing external 
insert for {}",
-                    table.getNameWithFullQualifiers(), e);
+            long updateTime = System.currentTimeMillis();
+            try {
+                
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateRowCountCache(table);
+                refreshTableInternal((ExternalDatabase) table.getDatabase(), 
table, updateTime);
+            } catch (RuntimeException e) {
+                // The external transaction is already committed. Still notify 
follower FEs even if a
+                // cache layer failed, so peers do not keep serving the 
pre-commit state.
+                LOG.warn("Failed to refresh table cache after committing 
external insert for {}",
+                        table.getNameWithFullQualifiers(), e);
+            }
+            ExternalObjectLog log = ExternalObjectLog.createForRefreshTable(
+                    table.getCatalog().getId(), 
table.getDatabase().getFullName(), table.getName(), updateTime);
+            Env.getCurrentEnv().getEditLog().logRefreshExternalTable(log);
+        } finally {
+            ((ExternalCatalog) table.getCatalog()).endTableLookupRefresh();
         }
-        ExternalObjectLog log = ExternalObjectLog.createForRefreshTable(
-                table.getCatalog().getId(), table.getDatabase().getFullName(), 
table.getName(), updateTime);
-        Env.getCurrentEnv().getEditLog().logRefreshExternalTable(log);
     }
 
     public void replayRefreshTable(ExternalObjectLog log) {
@@ -219,118 +239,123 @@ public class RefreshManager {
                 LOG.warn("failed to find catalog when replaying refresh table: 
{}", log.debugForRefreshTable());
                 return;
             }
-            invalidateLanceTableAccess(catalog, log);
-            Optional<ExternalDatabase<? extends ExternalTable>> db;
-            if (!Strings.isNullOrEmpty(log.getDbName())) {
-                db = catalog.getDbForReplay(log.getDbName());
-            } else {
-                db = catalog.getDbForReplay(log.getDbId());
-            }
-            // See comment in refreshDbInternal for why db and table may be 
null.
-            if (!db.isPresent()) {
-                invalidateColdReplayDatabase(catalog, log);
-                return;
-            }
-            if (!Strings.isNullOrEmpty(log.getNewTableName())) {
-                // this is a rename table op
-                db.get().unregisterTableForReplay(log.getTableName());
-                if 
(db.get().getTableForReplay(log.getNewTableName()).isPresent()
-                        || db.get().hasLocalTableName(log.getNewTableName())) {
-                    db.get().unregisterTableForReplay(log.getNewTableName());
-                }
-                db.get().resetMetaCacheNames();
-                return;
-            }
-            Optional<? extends ExternalTable> table;
-            if (!Strings.isNullOrEmpty(log.getTableName())) {
-                table = db.get().getTableForReplay(log.getTableName());
-            } else {
-                table = db.get().getTableForReplay(log.getTableId());
-            }
-            if (log.isInvalidateTableLookup()) {
-                if (table.isPresent()) {
-                    refreshTableInternal(db.get(), table.get(), 
log.getLastUpdateTime(), true);
+            catalog.beginTableLookupRefresh();
+            try {
+                invalidateLanceTableAccess(catalog, log);
+                Optional<ExternalDatabase<? extends ExternalTable>> db;
+                if (!Strings.isNullOrEmpty(log.getDbName())) {
+                    db = catalog.getDbForReplay(log.getDbName());
                 } else {
-                    try {
-                        db.get().invalidateTableLookup(log.getTableName());
-                    } finally {
-                        if (catalog.getLowerCaseTableNames() != 0) {
-                            db.get().invalidateTableLookupDependencies();
-                        } else {
-                            
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableByNameOrWider(
-                                    catalog.getId(), db.get().getFullName(), 
log.getTableName());
-                        }
-                    }
+                    db = catalog.getDbForReplay(log.getDbId());
                 }
-                return;
-            }
-            if (!table.isPresent()) {
-                LOG.warn("failed to find table when replaying refresh table: 
{}", log.debugForRefreshTable());
-                // Only a genuinely unresolved name (for example a lost 
case-insensitive mapping)
-                // needs the conservative database-wide retirement; an 
ordinary cold miss for a
-                // known name must not evict unrelated cached siblings.
-                if (!Strings.isNullOrEmpty(log.getTableName())
-                        && !db.get().hasLocalTableName(log.getTableName())) {
-                    db.get().retireAllTableObjectsWithoutEngineInvalidation();
+                // See comment in refreshDbInternal for why db and table may 
be null.
+                if (!db.isPresent()) {
+                    invalidateColdReplayDatabase(catalog, log);
+                    return;
                 }
-                // The independent row-count cache can outlive the table 
object, so fence the
-                // canonical database scope before acknowledging the committed 
refresh.
-                Env.getCurrentEnv().getExtMetaCacheMgr()
-                        .invalidateRowCountCache(catalog.getId(), 
db.get().getId());
+                if (!Strings.isNullOrEmpty(log.getNewTableName())) {
+                    // this is a rename table op
+                    db.get().unregisterTableForReplay(log.getTableName());
+                    if 
(db.get().getTableForReplay(log.getNewTableName()).isPresent()
+                            || 
db.get().hasLocalTableName(log.getNewTableName())) {
+                        
db.get().unregisterTableForReplay(log.getNewTableName());
+                    }
+                    db.get().resetMetaCacheNames();
+                    return;
+                }
+                Optional<? extends ExternalTable> table;
                 if (!Strings.isNullOrEmpty(log.getTableName())) {
-                    
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableByNameOrWider(
-                            catalog.getId(), db.get().getFullName(), 
log.getTableName());
+                    table = db.get().getTableForReplay(log.getTableName());
                 } else {
-                    // Legacy ID-only records cannot recover a cold table's 
engine key.
-                    Env.getCurrentEnv().getExtMetaCacheMgr().invalidateDb(
-                            catalog.getId(), db.get().getId(), 
db.get().getFullName());
+                    table = db.get().getTableForReplay(log.getTableId());
                 }
-                invalidatePaimonCatalogForReplay(catalog);
-                return;
-            }
-            List<String> modifiedPartNames = log.getPartitionNames();
-            List<String> newPartNames = log.getNewPartitionNames();
-            if (catalog instanceof HMSExternalCatalog
-                    && ((modifiedPartNames != null && 
!modifiedPartNames.isEmpty())
-                    || (newPartNames != null && !newPartNames.isEmpty()))) {
-                // Partition-level cache invalidation, only for hive catalog. 
Fence the held table
-                // before the hive(...) lookup can lazily initialize the group 
and throw; otherwise
-                // a failed acquisition skips the row-count fence and the 
full-table fallback.
-                
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateRowCountCache(table.get());
-                try {
-                    HiveExternalMetaCache cache = 
Env.getCurrentEnv().getExtMetaCacheMgr()
-                            .hive(catalog.getId());
-                    cache.refreshAffectedPartitionsCache((HMSExternalTable) 
table.get(), modifiedPartNames,
-                            newPartNames);
-                    // The held HMS table also caches the pre-insert table 
parameters.
-                    table.get().unsetObjectCreated();
-                    // Close the admission window the opening fence left open: 
a load admitted after
-                    // it can publish the pre-insert value from the 
still-resident file list.
+                if (log.isInvalidateTableLookup()) {
+                    if (table.isPresent()) {
+                        refreshTableInternal(db.get(), table.get(), 
log.getLastUpdateTime(), true);
+                    } else {
+                        try {
+                            db.get().invalidateTableLookup(log.getTableName());
+                        } finally {
+                            if (catalog.getLowerCaseTableNames() != 0) {
+                                db.get().invalidateTableLookupDependencies();
+                            } else {
+                                
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableByNameOrWider(
+                                        catalog.getId(), 
db.get().getFullName(), log.getTableName());
+                            }
+                        }
+                    }
+                    return;
+                }
+                if (!table.isPresent()) {
+                    LOG.warn("failed to find table when replaying refresh 
table: {}", log.debugForRefreshTable());
+                    // Only a genuinely unresolved name (for example a lost 
case-insensitive mapping)
+                    // needs the conservative database-wide retirement; an 
ordinary cold miss for a
+                    // known name must not evict unrelated cached siblings.
+                    if (!Strings.isNullOrEmpty(log.getTableName())
+                            && 
!db.get().hasLocalTableName(log.getTableName())) {
+                        
db.get().retireAllTableObjectsWithoutEngineInvalidation();
+                    }
+                    // The independent row-count cache can outlive the table 
object, so fence the
+                    // canonical database scope before acknowledging the 
committed refresh.
+                    Env.getCurrentEnv().getExtMetaCacheMgr()
+                            .invalidateRowCountCache(catalog.getId(), 
db.get().getId());
+                    if (!Strings.isNullOrEmpty(log.getTableName())) {
+                        
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableByNameOrWider(
+                                catalog.getId(), db.get().getFullName(), 
log.getTableName());
+                    } else {
+                        // Legacy ID-only records cannot recover a cold 
table's engine key.
+                        Env.getCurrentEnv().getExtMetaCacheMgr().invalidateDb(
+                                catalog.getId(), db.get().getId(), 
db.get().getFullName());
+                    }
+                    invalidatePaimonCatalogForReplay(catalog);
+                    return;
+                }
+                List<String> modifiedPartNames = log.getPartitionNames();
+                List<String> newPartNames = log.getNewPartitionNames();
+                if (catalog instanceof HMSExternalCatalog
+                        && ((modifiedPartNames != null && 
!modifiedPartNames.isEmpty())
+                        || (newPartNames != null && !newPartNames.isEmpty()))) 
{
+                    // Partition-level cache invalidation, only for hive 
catalog. Fence the held table
+                    // before the hive(...) lookup can lazily initialize the 
group and throw; otherwise
+                    // a failed acquisition skips the row-count fence and the 
full-table fallback.
                     
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateRowCountCache(table.get());
-                } catch (RuntimeException e) {
-                    // The insert is already committed. A partial or failed 
selective refresh must not
-                    // leave stale Hive partition/file entries, so fall back 
to the same conservative
-                    // full-table invalidation the leader uses.
-                    LOG.warn("failed to refresh affected partitions when 
replaying refresh table for {}",
-                            table.get().getNameWithFullQualifiers(), e);
                     try {
-                        
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache(table.get());
-                    } finally {
+                        HiveExternalMetaCache cache = 
Env.getCurrentEnv().getExtMetaCacheMgr()
+                                .hive(catalog.getId());
+                        
cache.refreshAffectedPartitionsCache((HMSExternalTable) table.get(), 
modifiedPartNames,
+                                newPartNames);
+                        // The held HMS table also caches the pre-insert table 
parameters.
                         table.get().unsetObjectCreated();
+                        // Close the admission window the opening fence left 
open: a load admitted after
+                        // it can publish the pre-insert value from the 
still-resident file list.
                         
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateRowCountCache(table.get());
+                    } catch (RuntimeException e) {
+                        // The insert is already committed. A partial or 
failed selective refresh must not
+                        // leave stale Hive partition/file entries, so fall 
back to the same conservative
+                        // full-table invalidation the leader uses.
+                        LOG.warn("failed to refresh affected partitions when 
replaying refresh table for {}",
+                                table.get().getNameWithFullQualifiers(), e);
+                        try {
+                            
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTableCache(table.get());
+                        } finally {
+                            table.get().unsetObjectCreated();
+                            
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateRowCountCache(table.get());
+                        }
                     }
+                    if (table.get() instanceof HMSExternalTable && 
log.getLastUpdateTime() > 0) {
+                        ((HMSExternalTable) 
table.get()).setUpdateTime(log.getLastUpdateTime());
+                    }
+                    LOG.info("replay refresh partitions for table {}, "
+                                    + "modified partitions count: {}, "
+                                    + "new partitions count: {}",
+                            table.get().getName(), modifiedPartNames == null ? 
0 : modifiedPartNames.size(),
+                            newPartNames == null ? 0 : newPartNames.size());
+                } else {
+                    // Full table cache invalidation
+                    refreshTableInternal(db.get(), table.get(), 
log.getLastUpdateTime());
                 }
-                if (table.get() instanceof HMSExternalTable && 
log.getLastUpdateTime() > 0) {
-                    ((HMSExternalTable) 
table.get()).setUpdateTime(log.getLastUpdateTime());
-                }
-                LOG.info("replay refresh partitions for table {}, "
-                                + "modified partitions count: {}, "
-                                + "new partitions count: {}",
-                        table.get().getName(), modifiedPartNames == null ? 0 : 
modifiedPartNames.size(),
-                        newPartNames == null ? 0 : newPartNames.size());
-            } else {
-                // Full table cache invalidation
-                refreshTableInternal(db.get(), table.get(), 
log.getLastUpdateTime());
+            } finally {
+                catalog.endTableLookupRefresh();
             }
         });
     }
@@ -348,25 +373,30 @@ public class RefreshManager {
                 && ((HMSExternalCatalog) 
catalog).isPartitionEventTargetExcluded(dbName, tableName)) {
             return;
         }
-        // Whole-table events are already committed remotely. Fence the row 
count by cached identity
-        // before any database/table reload can fail and make the not-found 
path return.
-        Env.getCurrentEnv().getExtMetaCacheMgr()
-                .invalidateRowCountCache(catalog.getId(), dbName, tableName);
-        DatabaseIf db = catalog.getDbNullable(dbName);
-        if (db == null) {
-            // Cold database: widen so independently resident engine entries 
are retired too.
+        ((ExternalCatalog) catalog).beginTableLookupRefresh();
+        try {
+            // Whole-table events are already committed remotely. Fence the 
row count by cached identity
+            // before any database/table reload can fail and make the 
not-found path return.
             Env.getCurrentEnv().getExtMetaCacheMgr()
-                    .invalidateTableByNameOrWider(catalog.getId(), dbName, 
tableName);
-            return;
-        }
+                    .invalidateRowCountCache(catalog.getId(), dbName, 
tableName);
+            DatabaseIf db = catalog.getDbNullable(dbName);
+            if (db == null) {
+                // Cold database: widen so independently resident engine 
entries are retired too.
+                Env.getCurrentEnv().getExtMetaCacheMgr()
+                        .invalidateTableByNameOrWider(catalog.getId(), dbName, 
tableName);
+                return;
+            }
 
-        TableIf table = db.getTableNullable(tableName);
-        if (table == null) {
-            Env.getCurrentEnv().getExtMetaCacheMgr()
-                    .invalidateTableByNameOrWider(catalog.getId(), dbName, 
tableName);
-            return;
+            TableIf table = db.getTableNullable(tableName);
+            if (table == null) {
+                Env.getCurrentEnv().getExtMetaCacheMgr()
+                        .invalidateTableByNameOrWider(catalog.getId(), dbName, 
tableName);
+                return;
+            }
+            refreshTableInternal((ExternalDatabase) db, (ExternalTable) table, 
updateTime);
+        } finally {
+            ((ExternalCatalog) catalog).endTableLookupRefresh();
         }
-        refreshTableInternal((ExternalDatabase) db, (ExternalTable) table, 
updateTime);
     }
 
     public void refreshTableInternal(ExternalDatabase db, ExternalTable table, 
long updateTime) {
@@ -484,16 +514,21 @@ public class RefreshManager {
                 && ((HMSExternalCatalog) 
catalog).isPartitionEventTargetExcluded(dbName, tableName)) {
             return;
         }
-        // Partition events are already committed remotely. Fence the row 
count by cached identity
-        // before any database/table reload can fail and make the 
ignored-not-found path return.
-        Env.getCurrentEnv().getExtMetaCacheMgr()
-                .invalidateRowCountCache(catalog.getId(), dbName, tableName);
+        ((ExternalCatalog) catalog).beginTableLookupRefresh();
         try {
-            refreshPartitionsAfterFence(catalog, dbName, tableName, 
partitionNames, updateTime,
-                    ignoreIfNotExists);
-        } finally {
+            // Partition events are already committed remotely. Fence the row 
count by cached identity
+            // before any database/table reload can fail and make the 
ignored-not-found path return.
             Env.getCurrentEnv().getExtMetaCacheMgr()
                     .invalidateRowCountCache(catalog.getId(), dbName, 
tableName);
+            try {
+                refreshPartitionsAfterFence(catalog, dbName, tableName, 
partitionNames, updateTime,
+                        ignoreIfNotExists);
+            } finally {
+                Env.getCurrentEnv().getExtMetaCacheMgr()
+                        .invalidateRowCountCache(catalog.getId(), dbName, 
tableName);
+            }
+        } finally {
+            ((ExternalCatalog) catalog).endTableLookupRefresh();
         }
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
index 342e0ca56d3..204574d3595 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
@@ -169,6 +169,10 @@ public abstract class ExternalCatalog
     @SerializedName(value = "initialized")
     protected boolean initialized = false;
     private AtomicLong metadataLoadEpoch = new AtomicLong();
+    // Catalog-owned so refresh protection survives normal database/table 
object eviction.
+    private transient volatile long tableLookupRefreshGeneration;
+    private transient int activeTableLookupRefreshes;
+
     @SerializedName(value = "lastUpdateTime")
     protected long lastUpdateTime;
     // <db name, table name> to tableAutoAnalyzePolicy
@@ -455,6 +459,25 @@ public abstract class ExternalCatalog
         }
     }
 
+    public synchronized void beginTableLookupRefresh() {
+        activeTableLookupRefreshes++;
+        tableLookupRefreshGeneration++;
+    }
+
+    public synchronized void endTableLookupRefresh() {
+        activeTableLookupRefreshes--;
+        tableLookupRefreshGeneration++;
+    }
+
+    synchronized OptionalLong observeTableLookupRefreshGeneration() {
+        return activeTableLookupRefreshes == 0
+                ? OptionalLong.of(tableLookupRefreshGeneration) : 
OptionalLong.empty();
+    }
+
+    long getTableLookupRefreshGeneration() {
+        return tableLookupRefreshGeneration;
+    }
+
     protected long acquireMetadataLoadEpoch() {
         synchronized (this) {
             makeSureInitialized();
@@ -708,32 +731,37 @@ public abstract class ExternalCatalog
      *                     and reloaded during the refresh process.
      */
     public void resetToUninitialized(boolean invalidCache) {
-        MetaCache<ExternalDatabase<? extends ExternalTable>> cacheToInvalidate 
= null;
-        Runnable objectInvalidation = null;
+        beginTableLookupRefresh();
         try {
-            synchronized (this) {
-                metadataLoadEpoch.incrementAndGet();
-                this.objectCreated = false;
-                this.initialized = false;
-                synchronized (this.confLock) {
-                    this.cachedConf = null;
+            MetaCache<ExternalDatabase<? extends ExternalTable>> 
cacheToInvalidate = null;
+            Runnable objectInvalidation = null;
+            try {
+                synchronized (this) {
+                    metadataLoadEpoch.incrementAndGet();
+                    this.objectCreated = false;
+                    this.initialized = false;
+                    synchronized (this.confLock) {
+                        this.cachedConf = null;
+                    }
+                    this.lowerCaseToDatabaseName.clear();
+                    cacheToInvalidate = metaCache;
+                    if (cacheToInvalidate != null) {
+                        cacheToInvalidate.invalidateNames();
+                        objectInvalidation = cacheToInvalidate.retireObjects();
+                    }
+                    onClose();
                 }
-                this.lowerCaseToDatabaseName.clear();
-                cacheToInvalidate = metaCache;
-                if (cacheToInvalidate != null) {
-                    cacheToInvalidate.invalidateNames();
-                    objectInvalidation = cacheToInvalidate.retireObjects();
+            } finally {
+                if (objectInvalidation != null) {
+                    objectInvalidation.run();
                 }
-                onClose();
             }
-        } finally {
-            if (objectInvalidation != null) {
-                objectInvalidation.run();
+            setLastUpdateTime(System.currentTimeMillis());
+            if (invalidCache) {
+                Env.getCurrentEnv().getExtMetaCacheMgr().invalidateCatalog(id);
             }
-        }
-        setLastUpdateTime(System.currentTimeMillis());
-        if (invalidCache) {
-            Env.getCurrentEnv().getExtMetaCacheMgr().invalidateCatalog(id);
+        } finally {
+            endTableLookupRefresh();
         }
     }
 
@@ -743,13 +771,18 @@ public abstract class ExternalCatalog
      * @param invalidCache
      */
     public void onRefreshCache(boolean invalidCache) {
-        setLastUpdateTime(System.currentTimeMillis());
+        beginTableLookupRefresh();
         try {
-            refreshMetaCacheOnly(invalidCache);
-        } finally {
-            if (invalidCache) {
-                Env.getCurrentEnv().getExtMetaCacheMgr().invalidateCatalog(id);
+            setLastUpdateTime(System.currentTimeMillis());
+            try {
+                refreshMetaCacheOnly(invalidCache);
+            } finally {
+                if (invalidCache) {
+                    
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateCatalog(id);
+                }
             }
+        } finally {
+            endTableLookupRefresh();
         }
     }
 
@@ -1139,6 +1172,8 @@ public abstract class ExternalCatalog
     public void gsonPostProcess() throws IOException {
         objectCreated = false;
         metadataLoadEpoch = new AtomicLong();
+        tableLookupRefreshGeneration = 0;
+        activeTableLookupRefreshes = 0;
         invalidateEngineCacheOnDatabaseRemoval = ThreadLocal.withInitial(() -> 
true);
         invalidatingAllMetaCache = ThreadLocal.withInitial(() -> false);
         // TODO: This code is to compatible with older version of metadata.
@@ -1677,8 +1712,13 @@ public abstract class ExternalCatalog
     }
 
     public void replayTruncateTable(TruncateTableInfo info) {
-        if (metadataOps != null) {
-            metadataOps.afterTruncateTable(info.getDb(), info.getTable(), 
info.getUpdateTime());
+        beginTableLookupRefresh();
+        try {
+            if (metadataOps != null) {
+                metadataOps.afterTruncateTable(info.getDb(), info.getTable(), 
info.getUpdateTime());
+            }
+        } finally {
+            endTableLookupRefresh();
         }
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java
index 5d69dee0bb2..1d9a23e0db8 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java
@@ -732,14 +732,16 @@ public abstract class ExternalDatabase<T extends 
ExternalTable>
     }
 
     public Optional<Runnable> beginTableMetadataRefresh() {
-        return metaCache == null ? Optional.empty() : 
Optional.of(metaCache.beginMetadataRefresh());
+        extCatalog.beginTableLookupRefresh();
+        return Optional.of(extCatalog::endTableLookupRefresh);
     }
 
     /** A missing result is authorized only against the retained DB, names and 
object generations. */
     @SuppressWarnings("unchecked")
     public Optional<Runnable> observeTableLookup(ExternalTable table) {
+        OptionalLong refreshGeneration = 
extCatalog.observeTableLookupRefreshGeneration();
         MetaCache<T> cache = metaCache;
-        if (!initialized || cache == null
+        if (!refreshGeneration.isPresent() || !initialized || cache == null
                 || 
extCatalog.shouldBypassTableNameCache(SessionContext.current())) {
             return Optional.empty();
         }
@@ -764,7 +766,8 @@ public abstract class ExternalDatabase<T extends 
ExternalTable>
             extCatalog.executeIfDatabaseCurrent(this, () -> {
                 synchronized (extCatalog) {
                     synchronized (this) {
-                        if (!initialized || metaCache != cache || 
!isTableMetadataLoadEpochCurrent(epoch)
+                        if (extCatalog.getTableLookupRefreshGeneration() != 
refreshGeneration.getAsLong()
+                                || !initialized || metaCache != cache || 
!isTableMetadataLoadEpochCurrent(epoch)
                                 || 
Env.getCurrentEnv().getCatalogMgr().getCatalog(extCatalog.getId()) != 
extCatalog) {
                             return false;
                         }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalLookupRefreshTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalLookupRefreshTest.java
index a115765bce4..778f433765d 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalLookupRefreshTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalLookupRefreshTest.java
@@ -33,6 +33,7 @@ import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
 import java.util.Optional;
+import java.util.OptionalLong;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -40,9 +41,213 @@ import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 
 class ExternalLookupRefreshTest {
+    @Test
+    void 
missingReadStartedDuringRefreshCannotRetireReplacementAfterRefreshFinishes() {
+        ExternalCatalog catalog = lookupRefreshCatalog();
+        Runnable finished = 
lookupDatabase(catalog).beginTableMetadataRefresh().get();
+        ExternalDatabase<?> replacement = lookupDatabase(catalog);
+        ExternalTable table = new ExternalTable();
+        table.name = "t";
+        table.setDb(replacement);
+        table.setRemoteName("t");
+        @SuppressWarnings("unchecked")
+        MetaCache<ExternalTable> cache = Deencapsulation.getField(replacement, 
"metaCache");
+        AtomicInteger repairs = new AtomicInteger();
+        Mockito.doReturn(Optional.of((Runnable) 
repairs::incrementAndGet)).when(cache).observeLookup(
+                Mockito.eq("t"), Mockito.eq("t"), Mockito.same(table), 
Mockito.eq(false), Mockito.any());
+        Mockito.doAnswer(call -> ((java.util.function.BooleanSupplier) 
call.getArgument(1)).getAsBoolean())
+                
.when(catalog).executeIfDatabaseCurrent(Mockito.same(replacement), 
Mockito.any());
+        Env env = Mockito.mock(Env.class);
+        CatalogMgr manager = Mockito.mock(CatalogMgr.class);
+        Mockito.when(env.getCatalogMgr()).thenReturn(manager);
+        Mockito.doReturn(catalog).when(manager).getCatalog(0L);
+        RuntimeException missing = new 
org.apache.iceberg.exceptions.NoSuchTableException("old query missed t");
+        try (MockedStatic<Env> mocked = Mockito.mockStatic(Env.class)) {
+            mocked.when(Env::getCurrentEnv).thenReturn(env);
+            Assertions.assertSame(missing, 
Assertions.assertThrows(RuntimeException.class,
+                    () -> table.withMissingTableRepair(() -> {
+                        // The remote response arrives after the refresh has 
finished. This query
+                        // began on an object admitted while the old database 
was being refreshed.
+                        finished.run();
+                        throw missing;
+                    })));
+            Assertions.assertEquals(0, repairs.get());
+            Assertions.assertSame(missing, 
Assertions.assertThrows(RuntimeException.class,
+                    () -> table.withMissingTableRepair(() -> {
+                        throw missing;
+                    })));
+            Assertions.assertEquals(1, repairs.get());
+        }
+    }
+
+    @Test
+    void refreshCommandsEventsAndReplayFenceColdDatabaseResolution() throws 
Exception {
+        for (int route = 0; route < 6; route++) {
+            ExternalCatalog catalog = lookupRefreshCatalog();
+            CatalogMgr manager = Mockito.mock(CatalogMgr.class);
+            Mockito.doReturn(catalog).when(manager).getCatalog("catalog");
+            Mockito.doReturn(catalog).when(manager).getCatalog(0L);
+            Env env = Mockito.mock(Env.class);
+            Mockito.when(env.getCatalogMgr()).thenReturn(manager);
+            ExternalMetaCacheMgr caches = 
Mockito.mock(ExternalMetaCacheMgr.class);
+            Mockito.when(env.getExtMetaCacheMgr()).thenReturn(caches);
+            org.mockito.stubbing.Answer<Object> duringResolution = call -> {
+                Assertions.assertFalse(Thread.holdsLock(catalog));
+                
Assertions.assertFalse(catalog.observeTableLookupRefreshGeneration().isPresent());
+                // Neither normal cache eviction nor the cold-cache path can 
reset the root fence.
+                
Assertions.assertFalse(lookupDatabase(catalog).observeTableLookup(new 
ExternalTable()).isPresent());
+                throw new IllegalStateException("database resolution failed");
+            };
+            
Mockito.doAnswer(duringResolution).when(catalog).getDbNullable("db");
+            
Mockito.doAnswer(duringResolution).when(catalog).getDbOrDdlException("db");
+            
Mockito.doAnswer(duringResolution).when(catalog).getDbForReplay("db");
+            RefreshManager refresh = new RefreshManager();
+            final int selected = route;
+            org.junit.jupiter.api.function.Executable run = () -> {
+                switch (selected) {
+                    case 0:
+                        refresh.handleRefreshDb("catalog", "db");
+                        break;
+                    case 1:
+                        refresh.handleRefreshTable("catalog", "db", "t", 
false);
+                        break;
+                    case 2:
+                        refresh.refreshExternalTableFromEvent("catalog", "db", 
"t", 1L);
+                        break;
+                    case 3:
+                        refresh.refreshPartitions("catalog", "db", "t", 
java.util.Collections.emptyList(), 1L, true);
+                        break;
+                    case 4:
+                        
refresh.replayRefreshDb(ExternalObjectLog.createForRefreshDb(catalog.getId(), 
"db"));
+                        break;
+                    default:
+                        
refresh.replayRefreshTable(ExternalObjectLog.createForRefreshTable(
+                                catalog.getId(), "db", "t", 1L, true));
+                        break;
+                }
+            };
+            try (MockedStatic<Env> mocked = Mockito.mockStatic(Env.class)) {
+                mocked.when(Env::getCurrentEnv).thenReturn(env);
+                if (route < 4) {
+                    Assertions.assertThrows(IllegalStateException.class, run);
+                } else {
+                    Assertions.assertDoesNotThrow(run);
+                }
+                
Assertions.assertTrue(catalog.observeTableLookupRefreshGeneration().isPresent());
+                Assertions.assertEquals(2, 
catalog.getTableLookupRefreshGeneration());
+            }
+        }
+    }
+
+    @Test
+    void catalogRefreshProtectsReplacementDatabaseAndOverlappingIntervals() {
+        ExternalCatalog catalog = lookupRefreshCatalog();
+        ExternalDatabase<?> oldDatabase = lookupDatabase(catalog);
+        Runnable finishFirst = oldDatabase.beginTableMetadataRefresh().get();
+        // Normal object-cache eviction admits a replacement with a new 
MetaCache. It must still
+        // inherit the catalog's active refresh interval instead of 
authorizing a delayed absence.
+        ExternalDatabase<?> replacement = lookupDatabase(catalog);
+        ExternalTable table = Mockito.mock(ExternalTable.class);
+        
Assertions.assertFalse(replacement.observeTableLookup(table).isPresent());
+        Runnable finishSecond = replacement.beginTableMetadataRefresh().get();
+        finishFirst.run();
+        
Assertions.assertFalse(catalog.observeTableLookupRefreshGeneration().isPresent());
+        
Assertions.assertFalse(replacement.observeTableLookup(table).isPresent());
+        finishSecond.run();
+        
Assertions.assertTrue(catalog.observeTableLookupRefreshGeneration().isPresent());
+        Assertions.assertEquals(4, catalog.getTableLookupRefreshGeneration());
+    }
+
+    @Test
+    void oldMissingResultCannotCleanRecreatedTableAfterRefresh() {
+        ExternalCatalog catalog = lookupRefreshCatalog();
+        ExternalDatabase<?> database = lookupDatabase(catalog);
+        ExternalTable table = Mockito.mock(ExternalTable.class);
+        Mockito.when(table.getName()).thenReturn("t");
+        Mockito.when(table.getRemoteName()).thenReturn("t");
+        @SuppressWarnings("unchecked")
+        MetaCache<ExternalTable> cache = Deencapsulation.getField(database, 
"metaCache");
+        AtomicInteger repairs = new AtomicInteger();
+        Mockito.doReturn(Optional.of((Runnable) 
repairs::incrementAndGet)).when(cache).observeLookup(
+                Mockito.eq("t"), Mockito.eq("t"), Mockito.same(table), 
Mockito.eq(false), Mockito.any());
+        Mockito.doAnswer(call -> ((java.util.function.BooleanSupplier) 
call.getArgument(1)).getAsBoolean())
+                
.when(catalog).executeIfDatabaseCurrent(Mockito.same(database), Mockito.any());
+        Env env = Mockito.mock(Env.class);
+        CatalogMgr manager = Mockito.mock(CatalogMgr.class);
+        Mockito.when(env.getCatalogMgr()).thenReturn(manager);
+        Mockito.doReturn(catalog).when(manager).getCatalog(0L);
+        try (MockedStatic<Env> mocked = Mockito.mockStatic(Env.class)) {
+            mocked.when(Env::getCurrentEnv).thenReturn(env);
+            Runnable oldMissing = database.observeTableLookup(table).get();
+            catalog.beginTableLookupRefresh();
+            
Assertions.assertFalse(database.observeTableLookup(table).isPresent());
+            catalog.endTableLookupRefresh();
+            oldMissing.run();
+            Assertions.assertEquals(0, repairs.get());
+            // A new observation remains eligible, so refresh does not 
permanently disable repair.
+            database.observeTableLookup(table).get().run();
+            Assertions.assertEquals(1, repairs.get());
+        }
+    }
+
+    @Test
+    void catalogRefreshAndTruncateReplayReleaseGuardOnFailure() {
+        ExternalCatalog catalog = lookupRefreshCatalog();
+        ExternalMetaCacheMgr caches = Mockito.mock(ExternalMetaCacheMgr.class);
+        Env env = Mockito.mock(Env.class);
+        Mockito.when(env.getExtMetaCacheMgr()).thenReturn(caches);
+        
Mockito.doCallRealMethod().when(catalog).onRefreshCache(Mockito.anyBoolean());
+        Mockito.doAnswer(call -> {
+            Assertions.assertFalse(Thread.holdsLock(catalog));
+            
Assertions.assertFalse(catalog.observeTableLookupRefreshGeneration().isPresent());
+            
Assertions.assertFalse(lookupDatabase(catalog).observeTableLookup(new 
ExternalTable()).isPresent());
+            throw new IllegalStateException("invalidation failed");
+        }).when(caches).invalidateCatalog(0L);
+        try (MockedStatic<Env> mocked = Mockito.mockStatic(Env.class)) {
+            mocked.when(Env::getCurrentEnv).thenReturn(env);
+            Assertions.assertThrows(IllegalStateException.class, () -> 
catalog.onRefreshCache(true));
+            
Assertions.assertTrue(catalog.observeTableLookupRefreshGeneration().isPresent());
+            catalog.onRefreshCache(false);
+            
Assertions.assertTrue(catalog.observeTableLookupRefreshGeneration().isPresent());
+        }
+        org.apache.doris.datasource.operations.ExternalMetadataOps ops =
+                
Mockito.mock(org.apache.doris.datasource.operations.ExternalMetadataOps.class);
+        Deencapsulation.setField(catalog, "metadataOps", ops);
+        
Mockito.doCallRealMethod().when(catalog).replayTruncateTable(Mockito.any());
+        Mockito.doAnswer(call -> {
+            
Assertions.assertFalse(catalog.observeTableLookupRefreshGeneration().isPresent());
+            throw new IllegalStateException("truncate invalidation failed");
+        }).when(ops).afterTruncateTable(Mockito.anyString(), 
Mockito.anyString(), Mockito.anyLong());
+        org.apache.doris.persist.TruncateTableInfo info = Mockito.mock(
+                org.apache.doris.persist.TruncateTableInfo.class);
+        Mockito.when(info.getDb()).thenReturn("db");
+        Mockito.when(info.getTable()).thenReturn("t");
+        Assertions.assertThrows(IllegalStateException.class, () -> 
catalog.replayTruncateTable(info));
+        
Assertions.assertTrue(catalog.observeTableLookupRefreshGeneration().isPresent());
+    }
+
+    private ExternalCatalog lookupRefreshCatalog() {
+        ExternalCatalog catalog = Mockito.mock(ExternalCatalog.class);
+        Mockito.doCallRealMethod().when(catalog).beginTableLookupRefresh();
+        Mockito.doCallRealMethod().when(catalog).endTableLookupRefresh();
+        
Mockito.when(catalog.observeTableLookupRefreshGeneration()).thenCallRealMethod();
+        
Mockito.when(catalog.getTableLookupRefreshGeneration()).thenCallRealMethod();
+        
Mockito.when(catalog.isMetadataLoadEpochCurrent(Mockito.anyLong())).thenReturn(true);
+        return catalog;
+    }
+
+    private ExternalDatabase<?> lookupDatabase(ExternalCatalog catalog) {
+        ExternalDatabase<?> database = new 
org.apache.doris.datasource.test.TestExternalDatabase(
+                catalog, 1L, "db", "db");
+        Deencapsulation.setField(database, "metaCache", 
Mockito.mock(MetaCache.class));
+        Deencapsulation.setField(database, "initialized", true);
+        return database;
+    }
+
     @Test
     void sessionBypassDoesNotObserveSharedLookup() {
         ExternalCatalog catalog = Mockito.mock(ExternalCatalog.class);
+        
Mockito.when(catalog.observeTableLookupRefreshGeneration()).thenReturn(OptionalLong.of(0));
         
Mockito.when(catalog.shouldBypassTableNameCache(Mockito.any())).thenReturn(true);
         ExternalDatabase<?> database = Mockito.mock(ExternalDatabase.class, 
Mockito.CALLS_REAL_METHODS);
         MetaCache<?> cache = Mockito.mock(MetaCache.class);
@@ -108,6 +313,7 @@ class ExternalLookupRefreshTest {
         Deencapsulation.setField(database, "metaCache", cache);
         Deencapsulation.setField(database, "initialized", true);
         
Mockito.when(catalog.isMetadataLoadEpochCurrent(Mockito.anyLong())).thenReturn(true);
+        
Mockito.when(catalog.observeTableLookupRefreshGeneration()).thenReturn(OptionalLong.of(0));
         Mockito.doReturn(Optional.of((Runnable) () -> { 
})).when(cache).observeLookup(
                 Mockito.eq("t"), Mockito.eq("t"), Mockito.same(table), 
Mockito.eq(false), Mockito.any());
         Mockito.doAnswer(call -> {


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

Reply via email to