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 8ce54eec191 [fix](fe) Preserve external database discovery errors
(#69252)
8ce54eec191 is described below
commit 8ce54eec1918bc080d45a283f625ac5905114968
Author: Gabriel <[email protected]>
AuthorDate: Thu Oct 22 11:27:52 2026 +0800
[fix](fe) Preserve external database discovery errors (#69252)
### What problem does this PR solve?
Problem Summary:
An external catalog initialization or database-discovery failure could
be reported as `Unknown database` because two lookup paths swallowed the
original exception. Preserve authentication, connection and discovery
errors; continue returning null when a successful lookup proves that the
database is absent.
Adapt the callers to the new error behavior:
- Return a MySQL ERR packet for COM_FIELD_LIST metadata failures and
keep the session usable.
- Retain the name of an unavailable forwarded default database so
catalog repair commands can run. When lookup succeeds, preserve the
database privilege check and reuse the resolved database instead of
repeating metadata lookup across a concurrent catalog reset.
- Preserve the previous database name/ID after a failed USE, and restore
the complete namespace after a failed remembered-database SWITCH.
- Restore external-view namespaces without remote metadata IO, and
restore thread-local context when materialized-view setup fails.
- Treat HTTP URL namespaces as defaults for name resolution. Both
SQL/schema handlers retain the catalog/database names without eagerly
loading external metadata; statements that actually depend on the
unavailable database still fail.
For a fresh Iceberg Hadoop catalog, its warehouse directory does not
exist until the first namespace is created. Treat only a missing
implicit Hadoop root as empty; missing named/REST roots and other
storage errors continue to propagate.
Kerberos realm mapping remains deployment configuration. This PR exposes
the underlying error and does not change Hadoop's shared authentication
state.
### Release note
Report external catalog initialization/discovery errors accurately,
preserve session state after failed namespace changes, and avoid
blocking independent queries or catalog repair commands on optional
default-database lookup.
### Check List (For Author)
- [x] Unit Test
- [x] Manual test
- Behavior changed: Yes; lookup failures retain their causes and callers
handle optional namespace setup and failure cleanup.
- Documentation needed: No new configuration or API.
Validation of the 2026-10-21 rebase:
- Rebased all five commits onto master
`02cb614b1ed5e11f21cd782a8af5b9432820a4e8`.
- The only conflict was the import block in `BindRelation.java`:
retained upstream `OlapChangelogTableWrapper` and this PR's
`NameSpaceContext`. `git range-diff` confirms all five patches are
preserved apart from that upstream import context.
- `git diff --check` and FE Checkstyle passed.
- Attempted the related tests through `run-fe-ut.sh`; the build stopped
before tests at the new Thrift compiler-version gate: master requires
0.24.0, while the configured local compiler is 0.16.0. No unit-test pass
is claimed for this rebased head; full validation is requested through
`run buildall`.
Validation before the 2026-10-21 rebase:
- Previous revision: `run-fe-ut.sh` passed 182 tests across nine related
FE test classes, with zero failures, errors or skips.
- Before the caller fixes, six deterministic assertions reproduced the
reviewed SWITCH, forwarded REFRESH/ALTER, HTTP schema, and HTTP
SQL-submission failures. Fault injection invalidates the catalog
immediately after the first successful forwarded lookup.
- Controls verify successful retry after failed SWITCH, errors on actual
unavailable-database access, forwarded database/catalog privilege
denial, and HTTP authentication rejection. HTTP schema tests exercise
real authentication and planning. HTTP execution tests cover the
controller-to-submitter boundary with a mocked StatementSubmitter; they
do not claim JDBC integration coverage.
- FE Checkstyle: zero violations, BUILD SUCCESS.
- Rebased only to resolve the actual conflict against master
`f6359ae80659`. The field-list handler retains both upstream
display-schema behavior and this PR's exception handling. The Paimon
CHAR test correction is already upstream and was dropped as an identical
patch.
Earlier local integration validation:
- Reproduced the original swallowed-error behavior and confirmed genuine
absence still reports `Unknown database`. Live Kerberos checks showed
the original realm-mapping error, and succeeded after compatible
configuration was applied.
- The complete Iceberg Hadoop/Kerberos regression passed with the
corrected connector, including reads, writes, rollback and snapshot/file
operations. Iceberg connector packaging passed 1446 tests with zero
failures/errors and four external-connectivity skips on the previous
base.
- Those live connector tests were not rerun on the current rebased head.
Full integration validation is delegated to CI.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../doris/connector/iceberg/IcebergCatalogOps.java | 13 +-
.../iceberg/IcebergHadoopCatalogNamespaceTest.java | 79 +++++
.../main/java/org/apache/doris/catalog/Env.java | 37 ++-
.../apache/doris/datasource/ExternalCatalog.java | 57 +---
.../doris/httpv2/rest/StmtExecutionAction.java | 13 +-
.../java/org/apache/doris/mtmv/MTMVPlanUtil.java | 43 ++-
.../doris/nereids/rules/analysis/BindRelation.java | 13 +-
.../mv/InitMaterializationContextHook.java | 4 +
.../java/org/apache/doris/qe/ConnectContext.java | 19 +-
.../java/org/apache/doris/qe/ConnectProcessor.java | 26 +-
.../org/apache/doris/qe/MysqlConnectProcessor.java | 41 +--
.../doris/datasource/ExternalCatalogTest.java | 40 +++
.../doris/qe/ExternalCatalogLookupFailureTest.java | 337 +++++++++++++++++++++
.../qe/ExternalCatalogNamespaceFailureTest.java | 266 ++++++++++++++++
14 files changed, 874 insertions(+), 114 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
index 8659fc77340..4968a141ab2 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
@@ -44,6 +44,7 @@ import org.apache.iceberg.catalog.ViewCatalog;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.expressions.Term;
+import org.apache.iceberg.hadoop.HadoopCatalog;
import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types;
import org.apache.iceberg.view.View;
@@ -318,7 +319,17 @@ public interface IcebergCatalogOps {
LOG.warn("Iceberg catalog does not support namespaces");
return Collections.emptyList();
}
- return listNestedNamespaces(rootNamespace());
+ Namespace root = rootNamespace();
+ try {
+ return listNestedNamespaces(root);
+ } catch (NoSuchNamespaceException e) {
+ // Hadoop creates the warehouse lazily with the first
namespace. Its absent implicit
+ // root is an empty catalog, unlike a missing named or REST
root; I/O errors still fail.
+ if (catalog instanceof HadoopCatalog && root.isEmpty()) {
+ return Collections.emptyList();
+ }
+ throw e;
+ }
}
/**
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergHadoopCatalogNamespaceTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergHadoopCatalogNamespaceTest.java
new file mode 100644
index 00000000000..d7ac72a5217
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergHadoopCatalogNamespaceTest.java
@@ -0,0 +1,79 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.iceberg;
+
+import
org.apache.doris.connector.iceberg.IcebergCatalogOps.CatalogBackedIcebergCatalogOps;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.exceptions.NoSuchNamespaceException;
+import org.apache.iceberg.exceptions.RuntimeIOException;
+import org.apache.iceberg.hadoop.HadoopCatalog;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+public class IcebergHadoopCatalogNamespaceTest {
+ @TempDir
+ Path tempDir;
+
+ @Test
+ public void freshWarehouseCanListAndCreateFirstDatabase() throws
IOException {
+ Path warehouse = tempDir.resolve("warehouse");
+ try (HadoopCatalog catalog = new HadoopCatalog(new
Configuration(false), warehouse.toString())) {
+ CatalogBackedIcebergCatalogOps ops = new
CatalogBackedIcebergCatalogOps(catalog);
+ Assertions.assertFalse(Files.exists(warehouse));
+ Assertions.assertTrue(ops.listDatabaseNames().isEmpty());
+ Assertions.assertFalse(Files.exists(warehouse), "Listing must not
create the warehouse");
+ ops.createDatabase("first_db", Collections.emptyMap());
+ Assertions.assertEquals(Collections.singletonList("first_db"),
ops.listDatabaseNames());
+ }
+ }
+
+ @Test
+ public void missingNamedHadoopRootStillFails() throws IOException {
+ try (HadoopCatalog catalog = new HadoopCatalog(new
Configuration(false),
+ tempDir.resolve("warehouse").toString())) {
+ CatalogBackedIcebergCatalogOps ops = new
CatalogBackedIcebergCatalogOps(
+ catalog, false, false, false, Optional.of("missing"));
+ Assertions.assertThrows(NoSuchNamespaceException.class,
ops::listDatabaseNames);
+ }
+ }
+
+ @Test
+ public void hadoopIoFailureIsNotAnEmptyCatalog() throws IOException {
+ RuntimeIOException failure = new RuntimeIOException(new
IOException("Storage unavailable"),
+ "Cannot list warehouse");
+ try (HadoopCatalog catalog = new HadoopCatalog() {
+ @Override
+ public List<Namespace> listNamespaces(Namespace namespace) {
+ throw failure;
+ }
+ }) {
+ Assertions.assertSame(failure,
Assertions.assertThrows(RuntimeIOException.class,
+ () -> new
CatalogBackedIcebergCatalogOps(catalog).listDatabaseNames()));
+ }
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java
index 19efb354a55..1b8c36a751e 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java
@@ -6653,21 +6653,28 @@ public class Env {
ctx.addLastDBOfCatalog(ctx.getCurrentCatalog().getName(),
currentDB);
}
}
- ctx.changeDefaultCatalog(catalogName);
- String lastDb = ctx.getLastDBOfCatalog(catalogName);
- if (StringUtils.isNotEmpty(lastDb)) {
- ctx.setDatabase(lastDb);
- }
- // A data source whose metadata model has no database layer can name
the single database Doris
- // presents for it; the connector owns that name. Asked of the
provider, not the connector, because
- // switching to a catalog must not force it to initialize. Applied
after the remembered database on
- // purpose: such a catalog has exactly one database, so there is
nothing else to return to.
- Map<String, String> catalogProps = catalogIf.getProperties();
- String catalogType = catalogProps.get(CatalogMgr.CATALOG_TYPE_PROP);
- if (catalogType != null) {
- ConnectorFactory.findProvider(catalogType, catalogProps)
- .flatMap(ConnectorProvider::defaultDatabaseOnUse)
- .ifPresent(ctx::setDatabase);
+ NameSpaceContext previousNamespace = ctx.getNameSpaceContext();
+ try {
+ ctx.changeDefaultCatalog(catalogName);
+ String lastDb = ctx.getLastDBOfCatalog(catalogName);
+ if (StringUtils.isNotEmpty(lastDb)) {
+ ctx.setDatabase(lastDb);
+ }
+ // A data source whose metadata model has no database layer can
name the single database Doris
+ // presents for it; the connector owns that name. Asked of the
provider, not the connector, because
+ // switching to a catalog must not force it to initialize. Applied
after the remembered database on
+ // purpose: such a catalog has exactly one database, so there is
nothing else to return to.
+ Map<String, String> catalogProps = catalogIf.getProperties();
+ String catalogType =
catalogProps.get(CatalogMgr.CATALOG_TYPE_PROP);
+ if (catalogType != null) {
+ ConnectorFactory.findProvider(catalogType, catalogProps)
+ .flatMap(ConnectorProvider::defaultDatabaseOnUse)
+ .ifPresent(ctx::setDatabase);
+ }
+ } catch (RuntimeException | Error e) {
+ // A failed remembered/default database lookup must not leave a
partially switched session.
+ ctx.restoreNameSpaceContext(previousNamespace);
+ throw e;
}
}
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 917d67a8de8..3934483b97b 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
@@ -779,17 +779,8 @@ public abstract class ExternalCatalog
if (StringUtils.isEmpty(dbName)) {
return null;
}
- try {
- makeSureInitialized();
- } catch (Exception e) {
- String diagnosticTrace = initErrorStackTrace(e);
- if (diagnosticTrace != null) {
- LOG.warn("failed to get db {} in catalog {}: {}", dbName,
name, diagnosticTrace);
- } else {
- LOG.warn("failed to get db {} in catalog {}", dbName, name, e);
- }
- return null;
- }
+ // Initialization errors do not establish that the requested database
is absent.
+ makeSureInitialized();
SessionContext sessionContext = SessionContext.current();
if (shouldBypassDbNameCache(sessionContext)) {
@@ -1098,42 +1089,14 @@ public abstract class ExternalCatalog
// When running ut, disable this check to make ut pass.
// Because in ut, the database is not created in remote system.
if (checkExists && (!FeConstants.runningUnitTest || this instanceof
TestExternalCatalog)) {
- try {
- final String lookupLocalDbName = localDbName;
- // Reuse the shared names lookup helper so existence checks
follow the same miss-refresh policy.
- Boolean exists =
resolveDatabaseNameFromSnapshot(lookupLocalDbName, false,
- namesValue ->
namesValue.containsLocalName(lookupLocalDbName) ? Boolean.TRUE : null);
- if (!Boolean.TRUE.equals(exists)) {
- LOG.warn("Database {} does not exist in the remote system.
Skipping initialization.",
- localDbName);
- return null;
- }
- } catch (RuntimeException e) {
- // Handle "Found conflicting" exception explicitly
- if (e.getMessage().contains(FOUND_CONFLICTING)) {
- LOG.error(e.getMessage());
- throw e; // Rethrow to let the caller handle this critical
issue
- } else {
- // Any errors other than name conflicts, we default to not
finding the database
- String diagnosticTrace = initErrorStackTrace(e);
- if (diagnosticTrace != null) {
- LOG.warn("Failed to check db {} exist in remote
system, ignore it: {}",
- localDbName, diagnosticTrace);
- } else {
- LOG.warn("Failed to check db {} exist in remote
system, ignore it.", localDbName, e);
- }
- return null;
- }
- } catch (Exception e) {
- // If connection failed, it will throw exception.
- // ignore it and treat it as not exist.
- String diagnosticTrace = initErrorStackTrace(e);
- if (diagnosticTrace != null) {
- LOG.warn("Failed to check db {} exist in remote system,
ignore it: {}",
- localDbName, diagnosticTrace);
- } else {
- LOG.warn("Failed to check db {} exist in remote system,
ignore it.", localDbName, e);
- }
+ // Authentication and connection failures must propagate; only a
successful lookup can prove absence.
+ final String lookupLocalDbName = localDbName;
+ // Reuse the shared names lookup helper so existence checks follow
the same miss-refresh policy.
+ Boolean exists =
resolveDatabaseNameFromSnapshot(lookupLocalDbName, false,
+ namesValue ->
namesValue.containsLocalName(lookupLocalDbName) ? Boolean.TRUE : null);
+ if (!Boolean.TRUE.equals(exists)) {
+ LOG.warn("Database {} does not exist in the remote system.
Skipping initialization.",
+ localDbName);
return null;
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
index 254ae250dab..e343fdf79fb 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StmtExecutionAction.java
@@ -18,6 +18,7 @@
package org.apache.doris.httpv2.rest;
import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.NameSpaceContext;
import org.apache.doris.catalog.TableIf;
import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.httpv2.entity.ResponseEntityBuilder;
@@ -110,8 +111,7 @@ public class StmtExecutionAction extends RestBaseController
{
LOG.info("stmt: {}, isSync:{}, limit: {}", stmtRequestBody.stmt,
stmtRequestBody.is_sync,
stmtRequestBody.limit);
- ConnectContext.get().changeDefaultCatalog(ns);
- ConnectContext.get().setDatabase(dbName);
+ setDefaultNamespace(ns, dbName);
String streamHeader = request.getHeader("X-Doris-Stream");
boolean isStream = !("false".equalsIgnoreCase(streamHeader));
@@ -145,8 +145,7 @@ public class StmtExecutionAction extends RestBaseController
{
sql = sql.replaceAll(NEW_LINE_PATTERN, NEW_LINE_REPLACEMENT);
}
LOG.info("sql: {}", sql);
- ConnectContext.get().changeDefaultCatalog(ns);
- ConnectContext.get().setDatabase(dbName);
+ setDefaultNamespace(ns, dbName);
return getSchema(sql);
}
@@ -212,6 +211,12 @@ public class StmtExecutionAction extends
RestBaseController {
}
}
+ private void setDefaultNamespace(String catalog, String database) {
+ // The URL supplies a default for name resolution, not a dependency of
every SQL statement.
+ // Resolve metadata only when the statement uses it, so an outage
cannot block catalog repair.
+ ConnectContext.get().restoreNameSpaceContext(new
NameSpaceContext(catalog, database, -1));
+ }
+
private static class StmtRequestBody {
public Boolean is_sync = true; // CHECKSTYLE IGNORE THIS LINE
public Long limit = DEFAULT_ROW_LIMIT;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
index 9620883030b..3b4d2efb06f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
@@ -215,23 +215,34 @@ public class MTMVPlanUtil {
ctx.setCurrentUserIdentity(UserIdentity.ADMIN);
ctx.getState().reset();
ctx.getState().setInternal(true);
- ctx.setThreadLocalInfo();
-
ctx.getSessionVariable().setAffectQueryResultInPlanSessionVariables(sessionVariables);
- // Debug session variable should be disabled when refreshed
- ctx.getSessionVariable().skipDeletePredicate = false;
- ctx.getSessionVariable().skipDeleteBitmap = false;
- ctx.getSessionVariable().skipDeleteSign = false;
- ctx.getSessionVariable().skipStorageEngineMerge = false;
- ctx.getSessionVariable().showHiddenColumns = false;
- ctx.getSessionVariable().allowModifyMaterializedViewData = true;
- ctx.getSessionVariable().setDisableNereidsRules(
-
disableRules.stream().map(RuleType::name).collect(Collectors.joining(",")));
- ctx.setStartTime();
- if (parentContext != null) {
- ctx.changeDefaultCatalog(parentContext.getDefaultCatalog());
- ctx.setDatabase(parentContext.getDatabase());
+ ConnectContext previousContext = ConnectContext.get();
+ try {
+ ctx.setThreadLocalInfo();
+
ctx.getSessionVariable().setAffectQueryResultInPlanSessionVariables(sessionVariables);
+ // Debug session variable should be disabled when refreshed
+ ctx.getSessionVariable().skipDeletePredicate = false;
+ ctx.getSessionVariable().skipDeleteBitmap = false;
+ ctx.getSessionVariable().skipDeleteSign = false;
+ ctx.getSessionVariable().skipStorageEngineMerge = false;
+ ctx.getSessionVariable().showHiddenColumns = false;
+ ctx.getSessionVariable().allowModifyMaterializedViewData = true;
+ ctx.getSessionVariable().setDisableNereidsRules(
+
disableRules.stream().map(RuleType::name).collect(Collectors.joining(",")));
+ ctx.setStartTime();
+ if (parentContext != null) {
+ ctx.changeDefaultCatalog(parentContext.getDefaultCatalog());
+ ctx.setDatabase(parentContext.getDatabase());
+ }
+ return ctx;
+ } catch (RuntimeException | Error e) {
+ // Initialization can fail before MTMVCache takes responsibility
for context cleanup.
+ if (previousContext == null) {
+ ConnectContext.remove();
+ } else {
+ previousContext.setThreadLocalInfo();
+ }
+ throw e;
}
- return ctx;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
index 012847288dc..87c4d800486 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
@@ -26,6 +26,7 @@ import org.apache.doris.catalog.DistributionInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.FunctionRegistry;
import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.NameSpaceContext;
import org.apache.doris.catalog.OlapChangelogTableWrapper;
import org.apache.doris.catalog.OlapTable;
import org.apache.doris.catalog.Partition;
@@ -977,20 +978,18 @@ public class BindRelation extends OneAnalysisRuleFactory {
ExternalTable table, String externalCatalog, String externalDb,
String ddlSql, CascadesContext cascadesContext) {
ConnectContext ctx = cascadesContext.getConnectContext();
- String previousCatalog = ctx.getCurrentCatalog().getName();
- String previousDb = ctx.getDatabase();
+ NameSpaceContext previousNamespace = ctx.getNameSpaceContext();
String convertedSql = SqlDialectHelper.convertSqlByDialect(ddlSql,
ctx.getSessionVariable());
// change catalog and db to external catalog and db,
// so that we can parse and analyze the view sql in external context.
- ctx.changeDefaultCatalog(externalCatalog);
- ctx.setDatabase(externalDb);
try {
+ ctx.changeDefaultCatalog(externalCatalog);
+ ctx.setDatabase(externalDb);
return new LogicalView<>(new ExternalView(table, ddlSql),
parseAndAnalyzeView(table, convertedSql, cascadesContext));
} finally {
- // restore catalog and db in connect context
- ctx.changeDefaultCatalog(previousCatalog);
- ctx.setDatabase(previousDb);
+ // Also restore failed namespace entry without masking analysis
errors with metadata IO.
+ ctx.restoreNameSpaceContext(previousNamespace);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/InitMaterializationContextHook.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/InitMaterializationContextHook.java
index c1642d6f433..0af2f843691 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/InitMaterializationContextHook.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/InitMaterializationContextHook.java
@@ -245,6 +245,7 @@ public class InitMaterializationContextHook implements
PlannerHook {
private List<MaterializationContext> createSyncMvContexts(OlapTable
olapTable,
CascadesContext cascadesContext) {
+ ConnectContext previousContext = ConnectContext.get();
int indexNumber = olapTable.getIndexNumber();
List<MaterializationContext> contexts = new ArrayList<>(indexNumber);
long baseIndexId = olapTable.getBaseIndexId();
@@ -312,6 +313,9 @@ public class InitMaterializationContextHook implements
PlannerHook {
LOG.warn(String.format("createSyncMvContexts exception, index
id is %s, index name is %s, "
+ "table name is %s", entry.getValue(),
indexName, olapTable.getQualifiedName()),
exception);
+ } finally {
+ // Database selection can fail after context creation but
before MTMVCache cleanup.
+ previousContext.setThreadLocalInfo();
}
}
return getMaterializationContextByHint(contexts);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
index 89b63ccadbf..8d93e63de81 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectContext.java
@@ -1002,10 +1002,25 @@ public class ConnectContext {
return new NameSpaceContext(defaultCatalog, currentDb, currentDbId);
}
+ public void restoreNameSpaceContext(NameSpaceContext namespace) {
+ // Restoring a captured namespace must not contact a catalog that may
now be unavailable.
+ defaultCatalog = namespace.getDefaultCatalog();
+ currentDb = namespace.getCurrentDb();
+ currentDbId = namespace.getCurrentDbId();
+ }
+
public void setDatabase(String db) {
- currentDb = db;
Optional<DatabaseIf> dbInstance = getCurrentCatalog().getDb(db);
- currentDbId = dbInstance.map(DatabaseIf::getId).orElse(-1L);
+ long dbId = dbInstance.map(DatabaseIf::getId).orElse(-1L);
+ // A failed lookup must leave the previously selected database name
and ID together.
+ currentDb = db;
+ currentDbId = dbId;
+ }
+
+ void restoreDatabaseName(String db) {
+ // A forwarded session can retain its namespace even when the
metastore cannot resolve the database ID.
+ currentDb = db;
+ currentDbId = -1;
}
public void setExecutor(StmtExecutor executor) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java
index 9190797ab34..b5699152df7 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java
@@ -35,6 +35,7 @@ import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.analysis.UuidLiteral;
import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.NameSpaceContext;
import org.apache.doris.catalog.Type;
import org.apache.doris.cloud.proto.Cloud;
import org.apache.doris.cloud.qe.ComputeGroupException;
@@ -43,6 +44,7 @@ import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.common.ConnectionException;
import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.ErrorReport;
import org.apache.doris.common.NotImplementedException;
import org.apache.doris.common.Pair;
import org.apache.doris.common.UserException;
@@ -54,6 +56,7 @@ import org.apache.doris.datasource.SessionContext;
import org.apache.doris.metric.MetricRepo;
import org.apache.doris.mysql.MysqlCommand;
import org.apache.doris.mysql.MysqlServerStatusFlag;
+import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.mysql.protocol.MysqlProtocolAdapter;
import org.apache.doris.nereids.SqlCacheContext;
import org.apache.doris.nereids.SqlCacheContext.CacheKeyType;
@@ -591,11 +594,28 @@ public abstract class ConnectProcessor {
CatalogIf catalog =
ctx.getEnv().getCatalogMgr().getCatalog(request.getDefaultCatalog());
if (catalog != null) {
ctx.getEnv().changeCatalog(ctx,
request.getDefaultCatalog());
- // Set default db only when the default catalog is set and
the dbname exists in default catalog.
+ // Restore the selected database after its catalog,
checking existence when metadata is available.
if (request.isSetDefaultDatabase()) {
- DatabaseIf db =
ctx.getCurrentCatalog().getDbNullable(request.getDefaultDatabase());
+ DatabaseIf db = null;
+ try {
+ db =
ctx.getCurrentCatalog().getDbNullable(request.getDefaultDatabase());
+ } catch (RuntimeException e) {
+ // Optional context restoration must not block
catalog repair commands. Retain the name
+ // so statements that actually use the database
still resolve it and report failures.
+
ctx.restoreDatabaseName(request.getDefaultDatabase());
+ LOG.debug("Deferring lookup of forwarded default
database {}.{}",
+ request.getDefaultCatalog(),
request.getDefaultDatabase(), e);
+ }
if (db != null) {
- ctx.getEnv().changeDb(ctx,
request.getDefaultDatabase());
+ if
(!ctx.getEnv().getAccessManager().checkDbPriv(ctx, ctx.getDefaultCatalog(),
+ request.getDefaultDatabase(),
PrivPredicate.SHOW)) {
+
ErrorReport.reportDdlException(ErrorCode.ERR_DBACCESS_DENIED_ERROR,
+ ctx.getQualifiedUser(),
request.getDefaultDatabase());
+ }
+ // Reuse the resolved database: a concurrent
refresh can make a second lookup fail
+ // before a catalog repair statement gets a chance
to execute.
+ ctx.restoreNameSpaceContext(new
NameSpaceContext(ctx.getDefaultCatalog(),
+ request.getDefaultDatabase(), db.getId()));
}
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
index 1486d9c210e..79e57b2316f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/MysqlConnectProcessor.java
@@ -431,29 +431,32 @@ public class MysqlConnectProcessor extends
ConnectProcessor {
ctx.getState().setError(ErrorCode.ERR_UNKNOWN_TABLE, "Empty
tableName");
return;
}
- DatabaseIf db =
ctx.getCurrentCatalog().getDbNullable(ctx.getDatabase());
- if (db == null) {
- ctx.getState().setError(ErrorCode.ERR_BAD_DB_ERROR, "Unknown
database(" + ctx.getDatabase() + ")");
- return;
- }
- TableIf table = db.getTableNullable(tableName);
- if (table == null) {
- ctx.getState().setError(ErrorCode.ERR_UNKNOWN_TABLE, "Unknown
table(" + tableName + ")");
- return;
- }
-
- table.readLock();
try {
- MysqlProtocolAdapter.of(ctx).resultSender(ctx)
- // COM_FIELD_LIST is a metadata statement: it must agree
with SHOW COLUMNS, so it serves
- // the display schema (the nullability the source
declares), not the read schema.
- .sendFieldList(db.getFullName(), table.getName(),
table.getBaseSchemaForDisplay());
+ // Metadata lookup can fail before a table is locked; return an
ERR packet instead of losing the session.
+ DatabaseIf db =
ctx.getCurrentCatalog().getDbNullable(ctx.getDatabase());
+ if (db == null) {
+ ctx.getState().setError(ErrorCode.ERR_BAD_DB_ERROR, "Unknown
database(" + ctx.getDatabase() + ")");
+ return;
+ }
+ TableIf table = db.getTableNullable(tableName);
+ if (table == null) {
+ ctx.getState().setError(ErrorCode.ERR_UNKNOWN_TABLE, "Unknown
table(" + tableName + ")");
+ return;
+ }
+
+ table.readLock();
+ try {
+ MysqlProtocolAdapter.of(ctx).resultSender(ctx)
+ // COM_FIELD_LIST is a metadata statement: it must
agree with SHOW COLUMNS, so it serves
+ // the display schema (the nullability the source
declares), not the read schema.
+ .sendFieldList(db.getFullName(), table.getName(),
table.getBaseSchemaForDisplay());
+ } finally {
+ table.readUnlock();
+ }
+ ctx.getState().setEof();
} catch (Throwable throwable) {
handleQueryException(throwable, "", null, null);
- } finally {
- table.readUnlock();
}
- ctx.getState().setEof();
}
private void handleChangeUser() throws IOException {
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java
index 511d881c811..bd6b22466af 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java
@@ -66,6 +66,7 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
public class ExternalCatalogTest extends TestWithFeService {
@@ -73,6 +74,45 @@ public class ExternalCatalogTest extends TestWithFeService {
private CatalogMgr mgr;
private ConnectContext rootCtx;
+ @Test
+ public void testDatabaseLookupPreservesInitializationFailure() {
+ IllegalStateException failure = new IllegalStateException();
+ IncrementalUpdateCatalog catalog = new IncrementalUpdateCatalog() {
+ @Override
+ protected void initLocalObjectsImpl() {
+ throw failure;
+ }
+ };
+ RuntimeException thrown =
Assertions.assertThrows(RuntimeException.class,
+ () -> catalog.getDbNullable("db_by_id"));
+ Assertions.assertSame(failure, thrown.getCause());
+ }
+
+ @Test
+ public void testDatabaseDiscoveryFailureIsNotMissingAndCanRecover() {
+ AtomicBoolean unavailable = new AtomicBoolean(true);
+ IllegalStateException failure = new IllegalStateException("Kerberos
principal has no matching rule");
+ IncrementalUpdateCatalog catalog = new IncrementalUpdateCatalog() {
+ @Override
+ protected List<String> listDatabaseNames() {
+ if (unavailable.get()) {
+ throw failure;
+ }
+ return super.listDatabaseNames();
+ }
+ };
+ RuntimeException thrown =
Assertions.assertThrows(RuntimeException.class,
+ () -> catalog.getDbNullable("db_by_id"));
+ Throwable cause = thrown;
+ while (cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ Assertions.assertSame(failure, cause);
+ unavailable.set(false);
+ Assertions.assertNotNull(catalog.getDbNullable("db_by_id"));
+ Assertions.assertNull(catalog.getDbNullable("missing_database"));
+ }
+
@Test
public void
testScopedWeightAlterStillInvalidatesRemoteBackendsAndRowCounts() throws
Exception {
Map<String, String> properties = Maps.newHashMap();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/qe/ExternalCatalogLookupFailureTest.java
b/fe/fe-core/src/test/java/org/apache/doris/qe/ExternalCatalogLookupFailureTest.java
new file mode 100644
index 00000000000..5c95ed07405
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/qe/ExternalCatalogLookupFailureTest.java
@@ -0,0 +1,337 @@
+// 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.qe;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.ExternalDatabase;
+import org.apache.doris.datasource.ExternalTable;
+import org.apache.doris.datasource.test.TestExternalCatalog;
+import org.apache.doris.httpv2.entity.ResponseBody;
+import org.apache.doris.httpv2.exception.UnauthorizedException;
+import org.apache.doris.httpv2.rest.StmtExecutionAction;
+import org.apache.doris.httpv2.util.ExecutionResultSet;
+import org.apache.doris.httpv2.util.StatementSubmitter;
+import org.apache.doris.mysql.DummyMysqlChannel;
+import org.apache.doris.mysql.protocol.MysqlProtocolAdapter;
+import org.apache.doris.qe.QueryState.MysqlStateType;
+import org.apache.doris.thrift.TMasterOpRequest;
+import org.apache.doris.thrift.TMasterOpResult;
+import org.apache.doris.utframe.TestWithFeService;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.springframework.http.ResponseEntity;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+public class ExternalCatalogLookupFailureTest extends TestWithFeService {
+ private static final String CATALOG = "lookup_failure_catalog";
+ private static final String DATABASE = "example_db";
+ private static final String FAILURE = "Metastore authentication failed";
+ private TestExternalCatalog catalog;
+ private boolean initializationUnavailable;
+ private boolean failAfterLookup;
+
+ @Override
+ protected void runBeforeAll() throws Exception {
+ executeSql("CREATE USER 'lookup_unprivileged_user'");
+ createDatabase(DATABASE);
+ connectContext.setDatabase(DATABASE);
+ createTable("CREATE TABLE healthy_table (id INT) DISTRIBUTED BY
HASH(id) BUCKETS 1 "
+ + "PROPERTIES ('replication_num'='1')");
+ }
+
+ @BeforeEach
+ public void prepareCatalog() {
+ FailingProvider.unavailable = false;
+ FailingProvider.lookupCount = 0;
+ initializationUnavailable = false;
+ failAfterLookup = false;
+ catalog = new TestExternalCatalog(Env.getCurrentEnv().getNextId(),
CATALOG, "",
+ new HashMap<>(Map.of("catalog_provider.class",
FailingProvider.class.getName())), "") {
+ @Override
+ public ExternalDatabase<? extends ExternalTable>
getDbNullable(String dbName) {
+ ExternalDatabase<? extends ExternalTable> db =
super.getDbNullable(dbName);
+ if (failAfterLookup) {
+ // Invalidate after the first successful probe, before
forwarding can publish its default database.
+ failAfterLookup = false;
+ resetToUninitialized(false);
+ initializationUnavailable = true;
+ }
+ return db;
+ }
+
+ @Override
+ protected void initLocalObjectsImpl() {
+ if (initializationUnavailable) {
+ throw new IllegalStateException(FAILURE);
+ }
+ }
+ };
+ Deencapsulation.invoke(Env.getCurrentEnv().getCatalogMgr(),
"addCatalog", catalog);
+ }
+
+ @AfterEach
+ public void removeCatalog() {
+ FailingProvider.unavailable = false;
+ Deencapsulation.invoke(Env.getCurrentEnv().getCatalogMgr(),
"removeCatalog", catalog.getId());
+ catalog.onClose();
+
Env.getCurrentEnv().getExtMetaCacheMgr().removeCatalog(catalog.getId());
+ connectContext.setThreadLocalInfo();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testFieldListReturnsErrorAndKeepsSessionUsable(boolean
failInitialization) throws Exception {
+ RecordingChannel channel = new RecordingChannel();
+ ConnectContext context = new ConnectContext(new
MysqlProtocolAdapter(channel));
+ context.setEnv(Env.getCurrentEnv());
+ context.setCurrentUserIdentity(UserIdentity.ROOT);
+ context.changeDefaultCatalog(CATALOG);
+ context.setDatabase(DATABASE);
+ context.setThreadLocalInfo();
+ catalog.resetToUninitialized(false);
+ initializationUnavailable = failInitialization;
+ FailingProvider.unavailable = true;
+ channel.request = ByteBuffer.wrap(new byte[] {4, 't', 0, 0});
+ MysqlConnectProcessor processor = new MysqlConnectProcessor(context);
+
+ Assertions.assertDoesNotThrow(processor::processOnce);
+ Assertions.assertEquals(MysqlStateType.ERR,
context.getState().getStateType());
+ Assertions.assertEquals(0xFF,
Byte.toUnsignedInt(channel.response.get(0)));
+
Assertions.assertTrue(StandardCharsets.UTF_8.decode(channel.response.duplicate()).toString().contains(FAILURE));
+ Assertions.assertFalse(context.isKilled());
+
+ channel.request = ByteBuffer.wrap(new byte[] {14});
+ processor.processOnce();
+ Assertions.assertEquals(0, channel.response.get(0));
+ Assertions.assertFalse(context.isKilled());
+ }
+
+ @Test
+ public void testForwardedRefreshSurvivesUnavailableDefaultDatabase()
throws Exception {
+ FailingProvider.unavailable = true;
+ TMasterOpResult result = forward("REFRESH CATALOG " + CATALOG);
+ Assertions.assertEquals(MysqlStateType.OK.name(), result.getStatus(),
result.getErrMessage());
+ }
+
+ @Test
+ public void testForwardedAlterSurvivesUnavailableDefaultDatabase() throws
Exception {
+ FailingProvider.unavailable = true;
+ TMasterOpResult result = forward("ALTER CATALOG " + CATALOG
+ + " SET PROPERTIES ('metadata_refresh_interval_sec' = '123')");
+ Assertions.assertEquals(MysqlStateType.OK.name(), result.getStatus(),
result.getErrMessage());
+ Assertions.assertEquals("123",
catalog.getProperties().get("metadata_refresh_interval_sec"));
+ }
+
+ @Test
+ public void testForwardedDatabaseAccessStillReportsFailure() throws
Exception {
+ FailingProvider.unavailable = true;
+ TMasterOpResult result = forward("SHOW TABLES");
+ Assertions.assertEquals(MysqlStateType.ERR.name(), result.getStatus());
+ Assertions.assertTrue(result.getErrMessage().contains(FAILURE),
result.getErrMessage());
+ Assertions.assertTrue(FailingProvider.lookupCount > 1, "The statement
must attempt database access");
+ }
+
+ @Test
+ public void testForwardedDatabaseAccessRestoresHealthyDefault() throws
Exception {
+ TMasterOpResult result = forward("SHOW TABLES");
+ Assertions.assertNotEquals(MysqlStateType.ERR.name(),
result.getStatus(), result.getErrMessage());
+ }
+
+ @Test
+ public void testForwardedAlterStillChecksCatalogPrivilege() throws
Exception {
+ FailingProvider.unavailable = true;
+ TMasterOpResult result = forward("ALTER CATALOG " + CATALOG
+ + " SET PROPERTIES ('metadata_refresh_interval_sec' = '123')",
+
UserIdentity.createAnalyzedUserIdentWithIp("lookup_unprivileged_user", "%"));
+ Assertions.assertEquals(MysqlStateType.ERR.name(), result.getStatus());
+ Assertions.assertTrue(result.getErrMessage().contains("Access denied
for user"), result.getErrMessage());
+
Assertions.assertNull(catalog.getProperties().get("metadata_refresh_interval_sec"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testForwardedRepairSurvivesResetAfterSuccessfulLookup(boolean
alter) throws Exception {
+ failAfterLookup = true;
+ String sql = alter ? "ALTER CATALOG " + CATALOG + " SET PROPERTIES
('metadata_refresh_interval_sec' = '123')"
+ : "REFRESH CATALOG " + CATALOG;
+ TMasterOpResult result = forward(sql);
+ Assertions.assertTrue(initializationUnavailable, "The successful
lookup must trigger the outage");
+ Assertions.assertEquals(MysqlStateType.OK.name(), result.getStatus(),
result.getErrMessage());
+ if (alter) {
+ Assertions.assertEquals("123",
catalog.getProperties().get("metadata_refresh_interval_sec"));
+ }
+ }
+
+ @Test
+ public void testForwardedActualAccessFailsAfterSuccessfulLookup() throws
Exception {
+ failAfterLookup = true;
+ TMasterOpResult result = forward("SHOW TABLES");
+ Assertions.assertTrue(initializationUnavailable);
+ Assertions.assertEquals(MysqlStateType.ERR.name(), result.getStatus());
+ Assertions.assertTrue(result.getErrMessage().contains(FAILURE),
result.getErrMessage());
+ }
+
+ @Test
+ public void testForwardedHealthyDefaultStillChecksDatabasePrivilege()
throws Exception {
+ TMasterOpResult result = forward("SELECT 1",
+
UserIdentity.createAnalyzedUserIdentWithIp("lookup_unprivileged_user", "%"));
+ Assertions.assertEquals(MysqlStateType.ERR.name(), result.getStatus());
+ Assertions.assertTrue(result.getErrMessage().contains("Access
denied"), result.getErrMessage());
+ }
+
+ @Test
+ public void testHttpSchemaIgnoresUnavailableDefaultDatabase() {
+ FailingProvider.unavailable = true;
+ Object schema = Assertions.assertDoesNotThrow(() -> new
StmtExecutionAction().querySchema(CATALOG, DATABASE,
+ httpRequest("root"), Mockito.mock(HttpServletResponse.class),
+ "SELECT * FROM internal." + DATABASE + ".healthy_table"));
+ Assertions.assertTrue(schema.toString().contains("CREATE TABLE"),
schema.toString());
+ Assertions.assertTrue(schema.toString().contains("healthy_table"),
schema.toString());
+ Assertions.assertEquals(CATALOG,
ConnectContext.get().getDefaultCatalog());
+ Assertions.assertEquals(DATABASE, ConnectContext.get().getDatabase());
+ }
+
+ @Test
+ public void testHttpSchemaActualDatabaseAccessStillFails() {
+ FailingProvider.unavailable = true;
+ RuntimeException error =
Assertions.assertThrows(RuntimeException.class,
+ () -> new StmtExecutionAction().querySchema(CATALOG, DATABASE,
+ httpRequest("root"),
Mockito.mock(HttpServletResponse.class), "SELECT * FROM t"));
+ Assertions.assertTrue(error.toString().contains(FAILURE),
error.toString());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testHttpEndpointsRejectUnauthenticatedRequests(boolean schema)
{
+ FailingProvider.unavailable = true;
+ StmtExecutionAction action = new StmtExecutionAction();
+ HttpServletRequest request = httpRequest("missing_http_user");
+ HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+ Assertions.assertThrows(UnauthorizedException.class, () -> {
+ if (schema) {
+ action.querySchema(CATALOG, DATABASE, request, response,
+ "SELECT * FROM internal." + DATABASE +
".healthy_table");
+ } else {
+ action.executeSQL(CATALOG, DATABASE, request, response,
+ "{\"stmt\":\"REFRESH CATALOG " + CATALOG + "\"}");
+ }
+ });
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void
testHttpRepairReachesSubmissionWithUnavailableDefaultDatabase(boolean alter)
throws Exception {
+ FailingProvider.unavailable = true;
+ StatementSubmitter original =
Deencapsulation.getField(StmtExecutionAction.class, StatementSubmitter.class);
+ StatementSubmitter submitter = Mockito.mock(StatementSubmitter.class);
+ Mockito.when(submitter.submit(Mockito.any())).thenReturn(
+ CompletableFuture.completedFuture(new
ExecutionResultSet(Map.of())));
+ Deencapsulation.setField(StmtExecutionAction.class, "stmtSubmitter",
submitter);
+ String sql = alter ? "ALTER CATALOG " + CATALOG + " SET PROPERTIES
('metadata_refresh_interval_sec' = '123')"
+ : "REFRESH CATALOG " + CATALOG;
+ try {
+ ResponseEntity<?> response = (ResponseEntity<?>)
Assertions.assertDoesNotThrow(
+ () -> new StmtExecutionAction().executeSQL(CATALOG,
DATABASE, httpRequest("root"),
+ Mockito.mock(HttpServletResponse.class),
"{\"stmt\":\"" + sql + "\"}"));
+ Assertions.assertEquals(0, ((ResponseBody<?>)
response.getBody()).getCode());
+ ArgumentCaptor<StatementSubmitter.StmtContext> submitted =
+
ArgumentCaptor.forClass(StatementSubmitter.StmtContext.class);
+ Mockito.verify(submitter).submit(submitted.capture());
+ Assertions.assertEquals(sql, submitted.getValue().stmt);
+ Assertions.assertEquals("root", submitted.getValue().user);
+ Assertions.assertEquals(CATALOG,
ConnectContext.get().getDefaultCatalog());
+ Assertions.assertEquals(DATABASE,
ConnectContext.get().getDatabase());
+ } finally {
+ Deencapsulation.setField(StmtExecutionAction.class,
"stmtSubmitter", original);
+ }
+ }
+
+ private HttpServletRequest httpRequest(String user) {
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+ Mockito.when(request.getScheme()).thenReturn("https");
+ Mockito.when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+ Mockito.when(request.getHeader("Authorization")).thenReturn("Basic "
+ + Base64.getEncoder().encodeToString((user +
":").getBytes(StandardCharsets.UTF_8)));
+ Mockito.when(request.getHeader("X-Doris-Stream")).thenReturn("false");
+ return request;
+ }
+
+ private TMasterOpResult forward(String sql) throws Exception {
+ return forward(sql, UserIdentity.ROOT);
+ }
+
+ private TMasterOpResult forward(String sql, UserIdentity user) throws
Exception {
+ TMasterOpRequest request = new TMasterOpRequest();
+ request.setDb(DATABASE);
+ request.setDefaultCatalog(CATALOG);
+ request.setDefaultDatabase(DATABASE);
+ request.setUser(user.getQualifiedUser());
+ request.setCurrentUserIdent(user.toThrift());
+ request.setSql(sql);
+ return new
MysqlConnectProcessor(ConnectContext.forMysqlProxy("lookup-failure-session")).proxyExecute(request);
+ }
+
+ public static class FailingProvider implements
TestExternalCatalog.TestCatalogProvider {
+ private static boolean unavailable;
+ private static int lookupCount;
+
+ @Override
+ public Map<String, Map<String, List<Column>>> getMetadata() {
+ lookupCount++;
+ if (unavailable) {
+ throw new IllegalStateException(FAILURE);
+ }
+ return Map.of(DATABASE, Map.of("t", List.of(new Column("id",
Type.INT))));
+ }
+ }
+
+ private static class RecordingChannel extends DummyMysqlChannel {
+ private ByteBuffer request;
+ private ByteBuffer response;
+
+ @Override
+ public ByteBuffer fetchOnePacket() {
+ return request;
+ }
+
+ @Override
+ public void sendAndFlush(ByteBuffer packet) {
+ response = packet.duplicate();
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/qe/ExternalCatalogNamespaceFailureTest.java
b/fe/fe-core/src/test/java/org/apache/doris/qe/ExternalCatalogNamespaceFailureTest.java
new file mode 100644
index 00000000000..e308f97b89a
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/qe/ExternalCatalogNamespaceFailureTest.java
@@ -0,0 +1,266 @@
+// 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.qe;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.NameSpaceContext;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.ExternalDatabase;
+import org.apache.doris.datasource.ExternalTable;
+import org.apache.doris.datasource.test.TestExternalCatalog;
+import org.apache.doris.mtmv.MTMVPlanUtil;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.parser.NereidsParser;
+import org.apache.doris.nereids.properties.PhysicalProperties;
+import org.apache.doris.nereids.rules.analysis.BindRelation;
+import
org.apache.doris.nereids.rules.exploration.mv.InitMaterializationContextHook;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.util.PlanChecker;
+import org.apache.doris.utframe.TestWithFeService;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class ExternalCatalogNamespaceFailureTest extends TestWithFeService {
+ private static final String DATABASE = "db_old";
+ private static final String INTERNAL_DB = "namespace_internal";
+ private static final String FAILURE = "Remote catalog unavailable";
+ private FailingCatalog previousCatalog;
+ private FailingCatalog viewCatalog;
+ private ExternalTable viewTable;
+
+ @Override
+ protected void runBeforeAll() throws Exception {
+ createDatabase(INTERNAL_DB);
+ connectContext.setDatabase(INTERNAL_DB);
+ createTable("CREATE TABLE t (k INT NOT NULL, v INT NOT NULL) "
+ + "DISTRIBUTED BY HASH(k) BUCKETS 1 PROPERTIES
('replication_num'='1')");
+ createMv("CREATE MATERIALIZED VIEW mv AS SELECT k AS mv_k, SUM(v) AS
mv_v FROM t GROUP BY k");
+ }
+
+ @BeforeEach
+ public void prepareCatalogs() {
+ previousCatalog = new FailingCatalog("namespace_previous");
+ viewCatalog = new FailingCatalog("namespace_view");
+ for (FailingCatalog catalog : List.of(previousCatalog, viewCatalog)) {
+ Deencapsulation.invoke(Env.getCurrentEnv().getCatalogMgr(),
"addCatalog", catalog);
+ }
+ connectContext.changeDefaultCatalog(previousCatalog.getName());
+ connectContext.setDatabase(DATABASE);
+ connectContext.setThreadLocalInfo();
+ viewTable = viewCatalog.getDbNullable(DATABASE).getTableNullable("t");
+ }
+
+ @AfterEach
+ public void removeCatalogs() {
+ connectContext.setThreadLocalInfo();
+ connectContext.changeDefaultCatalog("internal");
+ connectContext.setDatabase(INTERNAL_DB);
+ for (FailingCatalog catalog : List.of(previousCatalog, viewCatalog)) {
+ Deencapsulation.invoke(Env.getCurrentEnv().getCatalogMgr(),
"removeCatalog", catalog.getId());
+ catalog.onClose();
+
Env.getCurrentEnv().getExtMetaCacheMgr().removeCatalog(catalog.getId());
+ }
+ }
+
+ @Test
+ public void testFailedUseKeepsDatabaseNameAndIdAfterConcurrentRefresh() {
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ // Invalidate the cache exactly after Env.changeDb validates db_new,
before its setter's lookup.
+ previousCatalog.failAfterLookup = true;
+ RuntimeException error =
Assertions.assertThrows(RuntimeException.class,
+ () -> Env.getCurrentEnv().changeDb(connectContext, "db_new"));
+ Assertions.assertTrue(error.getMessage().contains(FAILURE),
error.getMessage());
+ assertNamespace(saved);
+ previousCatalog.unavailable = false;
+ Assertions.assertDoesNotThrow(() ->
Env.getCurrentEnv().changeDb(connectContext, "db_new"));
+ Assertions.assertEquals("db_new", connectContext.getDatabase());
+
Assertions.assertEquals(previousCatalog.getDbNullable("db_new").getId(),
connectContext.getCurrentDbId());
+ }
+
+ @Test
+ public void testFailedSwitchRestoresRememberedNamespace() throws Exception
{
+ Env.getCurrentEnv().changeCatalog(connectContext, "internal");
+ connectContext.setDatabase(INTERNAL_DB);
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ previousCatalog.makeUnavailable();
+
+ RuntimeException error =
Assertions.assertThrows(RuntimeException.class,
+ () -> Env.getCurrentEnv().changeCatalog(connectContext,
previousCatalog.getName()));
+ Assertions.assertTrue(error.getMessage().contains(FAILURE),
error.getMessage());
+ assertNamespace(saved);
+
+ previousCatalog.unavailable = false;
+ Env.getCurrentEnv().changeCatalog(connectContext,
previousCatalog.getName());
+ Assertions.assertEquals(previousCatalog.getName(),
connectContext.getDefaultCatalog());
+ Assertions.assertEquals(DATABASE, connectContext.getDatabase());
+
Assertions.assertEquals(previousCatalog.getDbNullable(DATABASE).getId(),
connectContext.getCurrentDbId());
+ }
+
+ @Test
+ public void testHealthyExternalViewRestoresUnavailablePreviousCatalog() {
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ previousCatalog.makeUnavailable();
+ Assertions.assertDoesNotThrow(() -> analyzeExternalView("SELECT id
FROM t"));
+ assertNamespace(saved);
+ }
+
+ @Test
+ public void testExternalViewEntryFailureRestoresPreviousNamespace() {
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ viewCatalog.makeUnavailable();
+ RuntimeException error =
Assertions.assertThrows(RuntimeException.class,
+ () -> analyzeExternalView("SELECT id FROM t"));
+ Assertions.assertTrue(error.getMessage().contains(FAILURE),
error.getMessage());
+ assertNamespace(saved);
+ }
+
+ @Test
+ public void testExternalViewAnalysisFailureIsNotMaskedByRestoration() {
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ previousCatalog.makeUnavailable();
+ RuntimeException error =
Assertions.assertThrows(RuntimeException.class,
+ () -> analyzeExternalView("SELECT * FROM missing_view_table"));
+
Assertions.assertTrue(error.getMessage().contains("missing_view_table"),
error.getMessage());
+ assertNamespace(saved);
+ }
+
+ @Test
+ public void testExternalViewPreservesEmptyNamespace() {
+ connectContext.changeDefaultCatalog("");
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ Assertions.assertDoesNotThrow(() -> analyzeExternalView("SELECT id
FROM t"));
+ assertNamespace(saved);
+ }
+
+ @Test
+ public void testHealthyMvContextCopiesNamespaceAndBecomesCurrent() {
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ ConnectContext mv = MTMVPlanUtil.createBasicMvContext(connectContext,
List.of(), Map.of());
+ Assertions.assertNotSame(connectContext, mv);
+ Assertions.assertSame(mv, ConnectContext.get());
+ Assertions.assertEquals(saved.getDefaultCatalog(),
mv.getDefaultCatalog());
+ Assertions.assertEquals(saved.getCurrentDb(), mv.getDatabase());
+ Assertions.assertEquals(saved.getCurrentDbId(), mv.getCurrentDbId());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testFailedMvContextConstructionPreservesThreadLocal(boolean
noPreviousContext) {
+ if (noPreviousContext) {
+ ConnectContext.remove();
+ }
+ previousCatalog.makeUnavailable();
+ Assertions.assertThrows(RuntimeException.class,
+ () -> MTMVPlanUtil.createBasicMvContext(connectContext,
List.of(), Map.of()));
+ Assertions.assertSame(noPreviousContext ? null : connectContext,
ConnectContext.get());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testSyncMvSetupFailureDoesNotBreakInternalQuery(boolean
failAfterContextCreation) {
+ connectContext.getSessionVariable().enableMaterializedViewRewrite =
true;
+ PlanChecker checker = PlanChecker.from(connectContext)
+ .analyze("SELECT k, SUM(v) FROM internal." + INTERNAL_DB + ".t
GROUP BY k");
+ CascadesContext cascadesContext = checker.getCascadesContext();
+ StatementContext statement = cascadesContext.getStatementContext();
+ OlapTable table = (OlapTable)
Env.getCurrentInternalCatalog().getDbNullable(INTERNAL_DB).getTableNullable("t");
+ Assertions.assertTrue(table.getVisibleIndexIdToMeta().size() > 1,
"Must exercise a visible sync MV");
+ NameSpaceContext saved = connectContext.getNameSpaceContext();
+ if (failAfterContextCreation) {
+ previousCatalog.failAfterLookup = true;
+ } else {
+ previousCatalog.makeUnavailable();
+ }
+ Assertions.assertDoesNotThrow(() ->
InitMaterializationContextHook.INSTANCE
+ .initMaterializationContext(cascadesContext));
+ Assertions.assertTrue(previousCatalog.unavailable, "MV setup must
attempt the default catalog lookup");
+ Assertions.assertSame(connectContext, ConnectContext.get());
+ Assertions.assertSame(statement,
ConnectContext.get().getStatementContext());
+ assertNamespace(saved);
+ Assertions.assertDoesNotThrow(() -> checker.rewrite().optimize());
+ }
+
+ private Plan analyzeExternalView(String sql) {
+ // The real binder parses the view under the second catalog and
restores the first catalog afterwards.
+ StatementContext statement = new StatementContext(connectContext, new
OriginStatement(sql, 0));
+ connectContext.setStatementContext(statement);
+ CascadesContext cascadesContext =
CascadesContext.initContext(statement,
+ new NereidsParser().parseSingle("SELECT 1"),
PhysicalProperties.ANY);
+ return Deencapsulation.invoke(new BindRelation(),
"parseAndAnalyzeExternalView", viewTable,
+ viewCatalog.getName(), DATABASE, sql, cascadesContext);
+ }
+
+ private void assertNamespace(NameSpaceContext saved) {
+ Assertions.assertEquals(saved.getDefaultCatalog(),
connectContext.getDefaultCatalog());
+ Assertions.assertEquals(saved.getCurrentDb(),
connectContext.getDatabase());
+ Assertions.assertEquals(saved.getCurrentDbId(),
connectContext.getCurrentDbId());
+ }
+
+ private static class FailingCatalog extends TestExternalCatalog {
+ private boolean unavailable;
+ private boolean failAfterLookup;
+
+ FailingCatalog(String name) {
+ super(Env.getCurrentEnv().getNextId(), name, "",
+ new HashMap<>(Map.of("catalog_provider.class",
Provider.class.getName())), "");
+ }
+
+ @Override
+ protected void initLocalObjectsImpl() {
+ if (unavailable) {
+ throw new IllegalStateException(FAILURE);
+ }
+ }
+
+ @Override
+ public ExternalDatabase<? extends ExternalTable> getDbNullable(String
dbName) {
+ ExternalDatabase<? extends ExternalTable> db =
super.getDbNullable(dbName);
+ if (failAfterLookup) {
+ failAfterLookup = false;
+ makeUnavailable();
+ }
+ return db;
+ }
+
+ void makeUnavailable() {
+ resetToUninitialized(false);
+ unavailable = true;
+ }
+ }
+
+ public static class Provider implements
TestExternalCatalog.TestCatalogProvider {
+ @Override
+ public Map<String, Map<String, List<Column>>> getMetadata() {
+ return Map.of(DATABASE, Map.of("t", List.of(new Column("id",
Type.INT))),
+ "db_new", Map.of("t", List.of(new Column("id",
Type.INT))));
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]