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

Gabriel39 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 f2b8e022359 [fix](metacache) Refresh external table lookups on a cold 
master (#69720)
f2b8e022359 is described below

commit f2b8e022359c06b9f095719305f16e0f72ae9582
Author: Gabriel <[email protected]>
AuthorDate: Thu Oct 29 11:55:52 2026 +0800

    [fix](metacache) Refresh external table lookups on a cold master (#69720)
    
    ### What problem does this PR solve?
    
    Related PR: #69501
    
    `external_table_p0/hive/test_hive_external_lookup_refresh.groovy` can
    fail in a multi-FE cluster depending on which FE serves the regression
    connection. `SHOW TABLES` and `DESC` populate the target catalog's
    lookup caches on the connected follower only. After the remote table is
    dropped through a separate catalog, `REFRESH TABLE` is forwarded to the
    master. If the master's target table cache is cold, the old
    implementation tries to load the now-deleted table and reports that it
    does not exist before invalidating caches or writing a refresh journal.
    The follower therefore retains stale table membership and the suite
    stops at `REFRESH TABLE`.
    
    Use the cached local table identity when available, then share
    name-based cache invalidation between command execution and journal
    replay through a private helper. The command validates the
    catalog/database and records the invalidation for other FEs; replay
    validates the journal and dispatches rename operations separately.
    Remove the unused `ignoreIfNotExists` argument to make this invalidation
    contract explicit. A remote table object is no longer required just to
    invalidate stale metadata. Catalog/database validation and the command's
    privilege checks remain in place.
    
    Coverage includes:
    
    - A cold master and a warm follower, with `lower_case_table_names` set
    to 0, 1, and 2; serialized refresh logs; follower table-name, object,
    and ID cache eviction; and table recreation.
    - Missing-database rejection without a journal, a cold table whose
    remote metadata loader must not be called, repeated refresh of a
    never-existing table name, and actual mixed-case master/follower
    identities in modes 1/2.
    - A regression suite that warms a non-master FE, issues refreshes
    through both master and non-master connections, checks all live FEs,
    repeats refresh after deletion, and verifies the recreated schema. The
    suite also runs on a single FE.
    
    The Iceberg nested-namespace suite also covers repeated refreshes of an
    absent table name using both fully qualified and current-catalog names.
    A subsequent query must still reject that table, and refresh must still
    reject a missing database. This replaces the old missing-table refresh
    exception assertion, which conflicts with the invalidation contract.
    
    ### Release note
    
    Fix external table refresh failures when a dropped table is cached on a
    follower but absent from the master's cache. `REFRESH TABLE` now accepts
    an absent table name in an existing external database as a cache
    invalidation request, so success does not depend on which FE retains an
    old table object.
    
    ### Check List (For Author)
    
    - Test
        - [x] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
    
    - Behavior changed:
    - [x] Yes. An absent table name in an existing external database can be
    explicitly refreshed to evict stale metadata on other FEs.
    
    - Does this need documentation?
    - [x] Yes: [English and Chinese REFRESH
    documentation](https://github.com/apache/doris-website/pull/4539).
    
    ### Validation
    
    - The new parameterized unit test fails on the original implementation
    for all three case modes with the same missing-table error.
    - 89 tests passed after the shared-helper refactor across
    `RefreshManagerTest`, `RefreshTableTest`,
    `PluginDrivenMissingTableTest`, and `ExternalDatabaseTest`.
    - Repository FE Checkstyle passed after the shared-helper refactor.
    - The new Groovy suite compiles.
    - At commit `e77bfeab48`, on a two-FE cluster with Hive 3 Metastore,
    both the original failing suite and the new multi-FE suite pass when the
    regression runner connects to the non-master FE. The original suite also
    passed with a master connection. The new suite issues refreshes through
    both FEs and checks cache visibility on each FE.
    
    - After the nested-namespace assertion update, `build.sh --fe -j 8`
    succeeds. Using that FE build with Iceberg REST 1.10.0 and MinIO, the
    original full nested-namespace suite reproduces the old exception
    assertion failure, while the updated full suite passes (1 suite, 0
    failures, 0 skips).
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 .../org/apache/doris/catalog/RefreshManager.java   |  77 ++++++------
 .../commands/refresh/RefreshTableCommand.java      |   2 +-
 .../apache/doris/catalog/RefreshManagerTest.java   | 140 ++++++++++++++++++++-
 .../org/apache/doris/catalog/RefreshTableTest.java |   2 +-
 .../plugin/PluginDrivenMissingTableTest.java       |   6 +-
 ...st_hive_external_lookup_refresh_multi_fe.groovy | 116 +++++++++++++++++
 .../iceberg_and_internal_nested_namespace.groovy   |  11 +-
 7 files changed, 309 insertions(+), 45 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 9c1eaaf899d..de558d6ec3e 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
@@ -142,8 +142,11 @@ public class RefreshManager {
         LOG.info("refresh database {} in catalog {}", db.getFullName(), 
db.getCatalog().getName());
     }
 
-    // Refresh table
-    public void handleRefreshTable(String catalogName, String dbName, String 
tableName, boolean ignoreIfNotExists)
+    /**
+     * Invalidates a table name in an existing external database without 
requiring a remote table.
+     * A cold master must still invalidate stale lookups retained by other FEs.
+     */
+    public void handleRefreshTable(String catalogName, String dbName, String 
tableName)
             throws DdlException {
         Env env = Env.getCurrentEnv();
         CatalogIf catalog = catalogName != null ? 
env.getCatalogMgr().getCatalog(catalogName) : env.getCurrentCatalog();
@@ -154,25 +157,19 @@ public class RefreshManager {
             throw new DdlException("Only support refresh ExternalCatalog 
Tables");
         }
 
-        DatabaseIf db = catalog.getDbNullable(dbName);
+        ExternalCatalog externalCatalog = (ExternalCatalog) catalog;
+        ExternalDatabase<?> db = externalCatalog.getDbNullable(dbName);
         if (db == null) {
-            if (!ignoreIfNotExists) {
-                throw new DdlException("Database " + dbName + " does not exist 
in catalog " + catalog.getName());
-            }
-            return;
+            throw new DdlException("Database " + dbName + " does not exist in 
catalog " + catalog.getName());
         }
 
-        TableIf table = db.getTableNullable(tableName);
-        if (table == null) {
-            if (!ignoreIfNotExists) {
-                throw new DdlException("Table " + tableName + " does not exist 
in db " + dbName);
-            }
-            return;
-        }
+        // Cached local names preserve mapped identities without depending on 
remote table availability.
+        String localTableName = db.getTableForReplay(tableName)
+                .map(ExternalTable::getName).orElse(tableName);
         long updateTime = System.currentTimeMillis();
-        refreshTableInternal((ExternalDatabase) db, (ExternalTable) table, 
updateTime, true);
         ExternalObjectLog log = 
ExternalObjectLog.createForExplicitRefreshTable(
-                catalog.getId(), db.getFullName(), table.getName(), 
updateTime);
+                catalog.getId(), db.getFullName(), localTableName, updateTime);
+        applyTableRefresh(externalCatalog, db.getFullName(), localTableName, 
updateTime, true);
         env.getEditLog().logRefreshExternalTable(log);
     }
 
@@ -198,27 +195,36 @@ public class RefreshManager {
             LOG.warn("refresh table replay log has no local name: {}", 
log.debugForRefreshTable());
             return;
         }
-        long dbId = Util.genIdByName(catalog.getName(), localDbName);
-        long tableId = Util.genIdByName(catalog.getName(), localDbName, 
localTableName);
-        Optional<ExternalDatabase<? extends ExternalTable>> db = 
catalog.getDbForReplay(localDbName);
         if (!Strings.isNullOrEmpty(log.getNewTableName())) {
-            replayRenameTable(log, catalog, db);
+            replayRenameTable(log, catalog, 
catalog.getDbForReplay(localDbName));
             return;
         }
+        applyTableRefresh(catalog, localDbName, localTableName,
+                log.getLastUpdateTime(), log.isInvalidateTableLookup());
+    }
+
+    /** Applies command and replay cache effects without remote table loading 
or journal writes. */
+    private void applyTableRefresh(ExternalCatalog catalog, String 
localDbName, String localTableName,
+            long updateTime, boolean invalidateTableLookup) {
+        long catalogId = catalog.getId();
+        long dbId = Util.genIdByName(catalog.getName(), localDbName);
+        long tableId = Util.genIdByName(catalog.getName(), localDbName, 
localTableName);
+        boolean invalidateDatabase = invalidateTableLookup && 
catalog.getLowerCaseTableNames() != 0;
+        ExternalMetaCacheMgr cacheMgr = 
Env.getCurrentEnv().getExtMetaCacheMgr();
+        Optional<ExternalDatabase<? extends ExternalTable>> db = 
catalog.getDbForReplay(localDbName);
         if (!db.isPresent()) {
             try {
                 invalidateAllConnectorCachesIfPresent(catalog);
             } finally {
-                if (log.isInvalidateTableLookup() && 
catalog.getLowerCaseTableNames() != 0) {
-                    
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateDb(log.getCatalogId(), dbId, 
localDbName);
+                if (invalidateDatabase) {
+                    cacheMgr.invalidateDb(catalogId, dbId, localDbName);
                 } else {
-                    Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTable(
-                            log.getCatalogId(), dbId, localDbName, tableId, 
localTableName);
+                    cacheMgr.invalidateTable(catalogId, dbId, localDbName, 
tableId, localTableName);
                 }
             }
-            LOG.info("database object cache is cold when replaying refresh 
table; "
-                            + "invalidated caches by local names {}.{}: {}",
-                    localDbName, localTableName, log.debugForRefreshTable());
+            LOG.info("database object cache is cold when refreshing table; "
+                            + "invalidated caches by local names {}.{} in 
catalog {}",
+                    localDbName, localTableName, catalogId);
             return;
         }
         Optional<? extends ExternalTable> table = 
db.get().getTableForReplay(localTableName);
@@ -230,24 +236,23 @@ public class RefreshManager {
                 }
             } finally {
                 try {
-                    if (log.isInvalidateTableLookup()) {
+                    if (invalidateTableLookup) {
                         db.get().invalidateTableLookup(localTableName);
                     }
                 } finally {
-                    if (log.isInvalidateTableLookup() && 
catalog.getLowerCaseTableNames() != 0) {
-                        Env.getCurrentEnv().getExtMetaCacheMgr().invalidateDb(
-                                log.getCatalogId(), dbId, 
db.get().getFullName());
+                    if (invalidateDatabase) {
+                        cacheMgr.invalidateDb(catalogId, dbId, 
db.get().getFullName());
                     } else {
-                        
Env.getCurrentEnv().getExtMetaCacheMgr().invalidateTable(
-                                log.getCatalogId(), dbId, 
db.get().getFullName(), tableId, localTableName);
+                        cacheMgr.invalidateTable(catalogId, dbId, 
db.get().getFullName(), tableId, localTableName);
                     }
                 }
             }
-            LOG.info("table object cache is cold when replaying refresh table; 
invalidated caches by local name {}: {}",
-                    localTableName, log.debugForRefreshTable());
+            LOG.info("table object cache is cold when refreshing table; "
+                            + "invalidated caches by local name {}.{} in 
catalog {}",
+                    db.get().getFullName(), localTableName, catalogId);
             return;
         }
-        refreshTableInternal(db.get(), table.get(), log.getLastUpdateTime(), 
log.isInvalidateTableLookup());
+        refreshTableInternal(db.get(), table.get(), updateTime, 
invalidateTableLookup);
     }
 
     private void replayRenameTable(ExternalObjectLog log, ExternalCatalog 
catalog,
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshTableCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshTableCommand.java
index 2b88537556d..07fc1073865 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshTableCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/refresh/RefreshTableCommand.java
@@ -52,7 +52,7 @@ public class RefreshTableCommand extends Command implements 
ForwardWithSync {
         checkRefreshTableAccess();
         // refresh table execute logic
         Env.getCurrentEnv().getRefreshManager()
-                .handleRefreshTable(tableNameInfo.getCtl(), 
tableNameInfo.getDb(), tableNameInfo.getTbl(), false);
+                .handleRefreshTable(tableNameInfo.getCtl(), 
tableNameInfo.getDb(), tableNameInfo.getTbl());
     }
 
     @Override
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshManagerTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshManagerTest.java
index dedd1b42b82..6fc0617eeac 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshManagerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshManagerTest.java
@@ -19,6 +19,7 @@ package org.apache.doris.catalog;
 
 import org.apache.doris.catalog.constraint.ConstraintManager;
 import org.apache.doris.catalog.info.TableNameInfo;
+import org.apache.doris.common.DdlException;
 import org.apache.doris.common.jmockit.Deencapsulation;
 import org.apache.doris.common.util.Util;
 import org.apache.doris.connector.cache.CacheSpec;
@@ -179,7 +180,7 @@ public class RefreshManagerTest {
         Assertions.assertNotNull(oldTable);
 
         MutableCatalogProvider.putTable("new_column");
-        new RefreshManager().handleRefreshTable(CATALOG_NAME, DATABASE_NAME, 
TABLE_NAME, false);
+        new RefreshManager().handleRefreshTable(CATALOG_NAME, DATABASE_NAME, 
TABLE_NAME);
 
         Assertions.assertNull(database.getCachedTableNamesForTest());
         Assertions.assertNull(database.getCachedTableForTest(TABLE_NAME));
@@ -190,6 +191,137 @@ public class RefreshManagerTest {
         Assertions.assertTrue(log.getValue().isInvalidateTableLookup());
     }
 
+    @ParameterizedTest
+    @ValueSource(ints = {0, 1, 2})
+    public void 
testExplicitRefreshOnColdMasterInvalidatesDroppedTableOnFollower(int mode) 
throws Exception {
+        useMutableCatalog(mode);
+        TestExternalCatalog followerCatalog = catalog;
+        CountingDatabase followerDatabase = database;
+        TestExternalTable staleTable = 
followerDatabase.getTableNullable(TABLE_NAME);
+        Assertions.assertNotNull(staleTable);
+        
Assertions.assertNotNull(followerDatabase.getCachedTableNamesForTest());
+
+        // Each FE owns its lookup cache; warming the follower does not warm 
the elected master.
+        useMutableCatalog(mode);
+        Assertions.assertNull(database.getCachedTableNamesForTest());
+        Assertions.assertNull(database.getCachedTableForTest(TABLE_NAME));
+        MutableCatalogProvider.removeTable();
+
+        Assertions.assertDoesNotThrow(() ->
+                new RefreshManager().handleRefreshTable(CATALOG_NAME, 
DATABASE_NAME, TABLE_NAME));
+
+        Assertions.assertEquals(0, database.buildTableCalls.get());
+        Assertions.assertNull(database.getCachedTableNamesForTest());
+        ArgumentCaptor<ExternalObjectLog> logCaptor = 
ArgumentCaptor.forClass(ExternalObjectLog.class);
+        Mockito.verify(editLog).logRefreshExternalTable(logCaptor.capture());
+        ExternalObjectLog log = GsonUtils.GSON.fromJson(
+                GsonUtils.GSON.toJson(logCaptor.getValue()), 
ExternalObjectLog.class);
+        Assertions.assertTrue(log.isInvalidateTableLookup());
+        Assertions.assertEquals(CATALOG_ID, log.getCatalogId());
+        Assertions.assertEquals(DATABASE_NAME, log.getDbName());
+        Assertions.assertEquals(TABLE_NAME, log.getTableName());
+
+        testingCatalogMgr.setCatalog(followerCatalog);
+        new RefreshManager().replayRefreshTable(log);
+
+        Assertions.assertNull(followerDatabase.getCachedTableNamesForTest());
+        
Assertions.assertNull(followerDatabase.getCachedTableForTest(TABLE_NAME));
+        
Assertions.assertNull(followerDatabase.getCachedTableNameByIdForTest(TABLE_ID));
+        
Assertions.assertTrue(followerDatabase.getTableNamesWithLock().isEmpty());
+        Assertions.assertNull(followerDatabase.getTableNullable(TABLE_NAME));
+
+        MutableCatalogProvider.putTable("replacement_column");
+        followerDatabase.invalidateTableLookup(TABLE_NAME);
+        TestExternalTable replacement = 
followerDatabase.getTableNullable(TABLE_NAME);
+        Assertions.assertNotNull(replacement);
+        Assertions.assertNotSame(staleTable, replacement);
+    }
+
+    @Test
+    public void testExplicitRefreshRejectsMissingDatabaseWithoutJournal() {
+        useMutableCatalog();
+
+        Assertions.assertThrows(DdlException.class, () ->
+                new RefreshManager().handleRefreshTable(CATALOG_NAME, 
"missing_db", TABLE_NAME));
+
+        Mockito.verifyNoInteractions(editLog);
+    }
+
+    @Test
+    public void testExplicitRefreshWithColdTableDoesNotLoadRemoteMetadata() 
throws Exception {
+        PluginCatalogFixture fixture = usePluginCatalog();
+        Mockito.when(fixture.connector.getMetadata(Mockito.any()))
+                .thenThrow(new AssertionError("Refresh must not load remote 
table metadata"));
+
+        new RefreshManager().handleRefreshTable(CATALOG_NAME, DATABASE_NAME, 
TABLE_NAME);
+
+        Assertions.assertEquals(0, fixture.database.buildTableCalls.get());
+        Assertions.assertNull(fixture.database.getCachedTableNamesForTest());
+        Mockito.verify(fixture.connector, 
Mockito.never()).getMetadata(Mockito.any());
+        Mockito.verify(fixture.connector).invalidateDb(DATABASE_NAME);
+        
Mockito.verify(editLog).logRefreshExternalTable(Mockito.any(ExternalObjectLog.class));
+    }
+
+    @Test
+    public void testExplicitRefreshAcceptsAbsentTableNameRepeatedly() throws 
Exception {
+        useMutableCatalog();
+        RefreshManager manager = new RefreshManager();
+
+        manager.handleRefreshTable(CATALOG_NAME, DATABASE_NAME, 
"absent_table");
+        manager.handleRefreshTable(CATALOG_NAME, DATABASE_NAME, 
"absent_table");
+
+        Assertions.assertEquals(0, database.buildTableCalls.get());
+        Assertions.assertNull(database.getCachedTableNamesForTest());
+        ArgumentCaptor<ExternalObjectLog> logs = 
ArgumentCaptor.forClass(ExternalObjectLog.class);
+        Mockito.verify(editLog, 
Mockito.times(2)).logRefreshExternalTable(logs.capture());
+        for (ExternalObjectLog log : logs.getAllValues()) {
+            Assertions.assertEquals("absent_table", log.getTableName());
+            Assertions.assertTrue(log.isInvalidateTableLookup());
+        }
+        Assertions.assertEquals(Collections.singleton(TABLE_NAME), 
database.getTableNamesWithLock());
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 2})
+    public void testColdMasterRefreshClearsMixedCaseFollowerIdentity(int mode) 
throws Exception {
+        useMutableCatalog(mode);
+        MutableCatalogProvider.removeTable();
+        String remoteName = "MiXeD_Table";
+        MutableCatalogProvider.putTable(remoteName, "old_column");
+        TestExternalCatalog followerCatalog = catalog;
+        CountingDatabase followerDatabase = database;
+        TestExternalTable staleTable = 
followerDatabase.getTableNullable("MIXED_TABLE");
+        Assertions.assertNotNull(staleTable);
+        String localName = staleTable.getName();
+
+        // The master receives the SQL spelling, while the follower retains 
its canonical local identity.
+        useMutableCatalog(mode);
+        MutableCatalogProvider.removeTable();
+        MutableCatalogProvider.METADATA.get(DATABASE_NAME).remove(remoteName);
+        new RefreshManager().handleRefreshTable(CATALOG_NAME, DATABASE_NAME, 
"MIXED_TABLE");
+        Assertions.assertEquals(0, database.buildTableCalls.get());
+        ArgumentCaptor<ExternalObjectLog> logCaptor = 
ArgumentCaptor.forClass(ExternalObjectLog.class);
+        Mockito.verify(editLog).logRefreshExternalTable(logCaptor.capture());
+        ExternalObjectLog log = GsonUtils.GSON.fromJson(
+                GsonUtils.GSON.toJson(logCaptor.getValue()), 
ExternalObjectLog.class);
+
+        testingCatalogMgr.setCatalog(followerCatalog);
+        new RefreshManager().replayRefreshTable(log);
+
+        Assertions.assertNull(followerDatabase.getCachedTableNamesForTest());
+        
Assertions.assertNull(followerDatabase.getCachedTableForTest(localName));
+        
Assertions.assertNull(followerDatabase.getCachedTableNameByIdForTest(staleTable.getId()));
+        
Assertions.assertTrue(followerDatabase.getTableNamesWithLock().isEmpty());
+        Mockito.verify(metaCacheMgr, 
Mockito.times(2)).invalidateDb(CATALOG_ID, DATABASE_ID, DATABASE_NAME);
+
+        MutableCatalogProvider.putTable(remoteName, "replacement_column");
+        followerDatabase.invalidateTableLookup("MIXED_TABLE");
+        TestExternalTable replacement = 
followerDatabase.getTableNullable("MIXED_TABLE");
+        Assertions.assertNotNull(replacement);
+        Assertions.assertNotSame(staleTable, replacement);
+        Assertions.assertEquals(localName, replacement.getName());
+    }
+
     @Test
     public void 
testExplicitRefreshReplayRetainsReplacementUpdateTimeBeforeSchemaLoad() {
         useMutableCatalog();
@@ -576,8 +708,12 @@ public class RefreshManagerTest {
         private static final Map<String, Map<String, List<Column>>> METADATA = 
new HashMap<>();
 
         static void putTable(String columnName) {
+            putTable(TABLE_NAME, columnName);
+        }
+
+        static void putTable(String tableName, String columnName) {
             METADATA.computeIfAbsent(DATABASE_NAME, ignored -> new HashMap<>())
-                    .put(TABLE_NAME, Collections.singletonList(new 
Column(columnName, PrimitiveType.INT)));
+                    .put(tableName, Collections.singletonList(new 
Column(columnName, PrimitiveType.INT)));
         }
 
         static void removeTable() {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshTableTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshTableTest.java
index 40252053a5d..668243583bc 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshTableTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/RefreshTableTest.java
@@ -97,7 +97,7 @@ public class RefreshTableTest extends TestWithFeService {
         TableNameInfo tableNameInfo = new TableNameInfo("test1", "db1", 
"tbl11");
         try {
             Env.getCurrentEnv().getRefreshManager()
-                    .handleRefreshTable(tableNameInfo.getCtl(), 
tableNameInfo.getDb(), tableNameInfo.getTbl(), false);
+                    .handleRefreshTable(tableNameInfo.getCtl(), 
tableNameInfo.getDb(), tableNameInfo.getTbl());
         } catch (Exception e) {
             // Do nothing
         }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMissingTableTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMissingTableTest.java
index 6062697ee67..75787df0b73 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMissingTableTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMissingTableTest.java
@@ -298,7 +298,7 @@ public class PluginDrivenMissingTableTest extends 
TestWithFeService {
             database.addTableForTest(replacement);
             schemas().put(new 
SchemaCacheKey(replacement.getOrBuildNameMapping()), 
replacement.initSchema().get());
         } else {
-            new RefreshManager().handleRefreshTable(catalog.getName(), "db", 
"a", false);
+            new RefreshManager().handleRefreshTable(catalog.getName(), "db", 
"a");
             replacement = database.getTableNullable("a");
         }
         List<Column> newSchema = replacement.getFullSchema();
@@ -464,7 +464,7 @@ public class PluginDrivenMissingTableTest extends 
TestWithFeService {
         try {
             Assertions.assertTrue(captured.await(10, TimeUnit.SECONDS));
             remoteTables.put("a", "new_column");
-            new RefreshManager().handleRefreshTable(catalog.getName(), "db", 
"a", false);
+            new RefreshManager().handleRefreshTable(catalog.getName(), "db", 
"a");
             Assertions.assertNull(original.getName(old.getId()));
             proceed.countDown();
             PluginDrivenExternalTable replacement = lookup.get(10, 
TimeUnit.SECONDS);
@@ -493,7 +493,7 @@ public class PluginDrivenMissingTableTest extends 
TestWithFeService {
         remoteCache.put("FOO", "second");
         remoteTables.remove("FOO");
         remoteTables.put("Foo", "third");
-        new RefreshManager().handleRefreshTable(catalog.getName(), "db", 
"foo", false);
+        new RefreshManager().handleRefreshTable(catalog.getName(), "db", 
"foo");
         Assertions.assertTrue(remoteCache.isEmpty());
         PluginDrivenExternalTable replacement = 
database.getTableNullable("foo");
         Assertions.assertEquals("Foo", replacement.getRemoteName());
diff --git 
a/regression-test/suites/external_table_p0/hive/test_hive_external_lookup_refresh_multi_fe.groovy
 
b/regression-test/suites/external_table_p0/hive/test_hive_external_lookup_refresh_multi_fe.groovy
new file mode 100644
index 00000000000..4c377babbf0
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/hive/test_hive_external_lookup_refresh_multi_fe.groovy
@@ -0,0 +1,116 @@
+// 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.
+
+suite("test_hive_external_lookup_refresh_multi_fe", "p0,external") {
+    String enabled = context.config.otherConfigs.get("enableHiveTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable Hive test.")
+        return
+    }
+
+    def frontends = sql_return_maparray("SHOW FRONTENDS").findAll { 
it.Alive.toBoolean() }
+    def master = frontends.find { it.IsMaster.toBoolean() }
+    assertNotNull(master)
+    def follower = frontends.find { !it.IsMaster.toBoolean() }
+    def refreshEndpoints = follower == null ? [master] : [master, follower]
+    def warmEndpoint = follower == null ? master : follower
+    def onFrontend = { frontend, action ->
+        String host = frontend.Host.contains(":") ? "[${frontend.Host}]" : 
frontend.Host
+        String authority = "jdbc:mysql://${host}:${frontend.QueryPort}"
+        int queryOffset = context.config.jdbcUrl.indexOf('?')
+        String options = queryOffset < 0 ? "" : 
context.config.jdbcUrl.substring(queryOffset)
+        String url = "${authority}/${options}"
+        connect(context.config.jdbcUser, context.config.jdbcPassword, url) {
+            sql "SET force_forward_all_queries = false"
+            // Wait for catalog DDL and refresh journals without warming any 
external table on the master.
+            sql "SYNC"
+            action()
+        }
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String hmsPort = context.config.otherConfigs.get("hive3HmsPort")
+    String hdfsPort = context.config.otherConfigs.get("hive3HdfsPort")
+    refreshEndpoints.eachWithIndex { endpoint, index ->
+        String source = "hive_refresh_multi_source_${index}"
+        String target = "hive_refresh_multi_target_${index}"
+        String database = "hive_refresh_multi_db_${index}"
+        try {
+            onFrontend(master) {
+                [source, target].each { catalog ->
+                    sql "DROP CATALOG IF EXISTS ${catalog}"
+                    sql """CREATE CATALOG ${catalog} PROPERTIES (
+                        'type' = 'hms',
+                        'hadoop.username' = 'hadoop',
+                        'hive.metastore.uris' = 
'thrift://${externalEnvIp}:${hmsPort}',
+                        'fs.defaultFS' = 'hdfs://${externalEnvIp}:${hdfsPort}'
+                    )"""
+                }
+                sql "DROP DATABASE IF EXISTS ${source}.${database} FORCE"
+                sql "CREATE DATABASE ${source}.${database}"
+                sql "CREATE TABLE ${source}.${database}.refresh_target 
(before_col INT)"
+                sql "CREATE TABLE ${source}.${database}.refresh_sibling 
(sibling_col INT)"
+            }
+
+            // Only the follower retains the old target object; REFRESH must 
also work on a cold master.
+            onFrontend(warmEndpoint) {
+                assertEquals([["refresh_sibling"], ["refresh_target"]],
+                        sql("SHOW TABLES FROM ${target}.${database}").sort { 
it[0] })
+                assertEquals("before_col", sql("DESC 
${target}.${database}.refresh_target")[0][0])
+            }
+            onFrontend(master) {
+                sql "DROP TABLE ${source}.${database}.refresh_target"
+            }
+            onFrontend(endpoint) {
+                sql "REFRESH TABLE ${target}.${database}.refresh_target"
+                // A second refresh must remain valid after all cached copies 
of the dropped table are gone.
+                sql "REFRESH TABLE ${target}.${database}.refresh_target"
+            }
+            frontends.each { frontend ->
+                onFrontend(frontend) {
+                    // Check membership before a failed lookup can repair it 
as a side effect.
+                    assertEquals([["refresh_sibling"]], sql("SHOW TABLES FROM 
${target}.${database}"))
+                    test {
+                        sql "SELECT * FROM 
${target}.${database}.refresh_target"
+                        exception "does not exist"
+                    }
+                    assertEquals("sibling_col", sql("DESC 
${target}.${database}.refresh_sibling")[0][0])
+                }
+            }
+
+            onFrontend(master) {
+                sql "CREATE TABLE ${source}.${database}.refresh_target 
(after_col BIGINT)"
+            }
+            onFrontend(endpoint) {
+                sql "REFRESH TABLE ${target}.${database}.refresh_target"
+            }
+            frontends.each { frontend ->
+                onFrontend(frontend) {
+                    assertEquals([["refresh_sibling"], ["refresh_target"]],
+                            sql("SHOW TABLES FROM ${target}.${database}").sort 
{ it[0] })
+                    assertEquals("after_col", sql("DESC 
${target}.${database}.refresh_target")[0][0])
+                }
+            }
+        } finally {
+            onFrontend(master) {
+                try_sql "DROP DATABASE IF EXISTS ${source}.${database} FORCE"
+                try_sql "DROP CATALOG IF EXISTS ${target}"
+                try_sql "DROP CATALOG IF EXISTS ${source}"
+            }
+        }
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/iceberg/iceberg_and_internal_nested_namespace.groovy
 
b/regression-test/suites/external_table_p0/iceberg/iceberg_and_internal_nested_namespace.groovy
index abf060b85ce..f4e11f6dde1 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/iceberg_and_internal_nested_namespace.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/iceberg_and_internal_nested_namespace.groovy
@@ -204,9 +204,16 @@ suite("iceberg_and_internal_nested_namespace", 
"p0,external") {
     sql """refresh database `ns1.ns2`"""
     sql """refresh table ${catalog_name}.`ns1.ns2`.nested_tbl2"""
     sql """refresh table `ns1.ns2`.nested_tbl2"""
+    // Cache invalidation must succeed even when no table object exists on the 
executing FE.
+    sql """refresh table ${catalog_name}.`ns1.ns2`.nested_tbl2xxx"""
+    sql """refresh table `ns1.ns2`.nested_tbl2xxx"""
     test {
-        sql """refresh table `ns1.ns2`.nested_tbl2xxx"""
-        exception """Table nested_tbl2xxx does not exist in db ns1.ns2"""
+        sql """select * from `ns1.ns2`.nested_tbl2xxx"""
+        exception """Table [nested_tbl2xxx] does not exist in database 
[ns1.ns2]"""
+    }
+    test {
+        sql """refresh table ${catalog_name}.`ns1.ns2.missing`.nested_tbl2"""
+        exception """Database ns1.ns2.missing does not exist in catalog 
${catalog_name}"""
     }
     // drop ns1.ns2 first, we can still see it after refresh, because 
ns1.ns2.ns3 still exists
     sql """drop database `ns1.ns2` force"""


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

Reply via email to