Search before asking
Version
Verified against the following upstream heads on 2026-09-01:
branch-4.0: 8a9961723ea4be00cdf923c60759607202c7e2e7
branch-4.1: 6f4c6a4be42ab3f4e1811982703d4b71b5a8ea3c
master: feb9e04f78490296c3393cbd594aef617af6b433
Related preload PRs:
What is wrong?
JDBC external tables do not have a consistent, cache-safe failure contract when remote metadata returns no columns or the remote table handle can no longer be resolved.
branch-4.0 and branch-4.1
The failure chain is:
JdbcExternalTable.initSchema() returns Optional.empty() when listColumns() returns null or an empty list.
- The external schema cache stores the negative result.
ExternalTable.getFullSchema() maps the empty value to null.
getBaseSchema() also returns null.
LogicalCatalogRelation.computeOutput() calls table.getBaseSchema().stream() and throws a null pointer exception.
Typical error:
Cannot invoke "java.util.List.stream()" because the return value of
"org.apache.doris.catalog.TableIf.getBaseSchema()" is null
branch-4.1 additionally supports JDBC metadata preload through #64579. When enable_preload_external_metadata=true and a mixed query contains both an internal table requiring a plan-time read lock and a JDBC table, PreloadExternalMetadata calls getBaseSchema() before locking but does not validate its result. Analysis later reaches the same stream() NPE. Disabling preload does not remove the underlying bug; it only changes when the schema is loaded.
branch-4.0 does not contain the preload feature, but regular JDBC query planning still has the same nullable-schema chain.
Relevant code:
- branch-4.0 JDBC loader:
|
public Optional<SchemaCacheValue> initSchema() { |
|
String remoteDbName = ((ExternalDatabase<?>) this.getDatabase()).getRemoteName(); |
|
|
|
// 1. Retrieve remote column information |
|
List<Column> columns = ((JdbcExternalCatalog) catalog).listColumns(remoteDbName, remoteName); |
|
if (columns == null || columns.isEmpty()) { |
|
return Optional.empty(); |
|
} |
- branch-4.0 nullable schema:
|
@Override |
|
public List<Column> getFullSchema() { |
|
ExternalSchemaCache cache = Env.getCurrentEnv().getExtMetaCacheMgr().getSchemaCache(catalog); |
|
Optional<SchemaCacheValue> schemaCacheValue = cache.getSchemaValue(new SchemaCacheKey(getOrBuildNameMapping())); |
|
return schemaCacheValue.map(SchemaCacheValue::getSchema).orElse(null); |
|
} |
|
|
|
@Override |
|
public List<Column> getBaseSchema() { |
|
return getFullSchema(); |
- branch-4.1 JDBC loader:
|
public boolean supportsExternalMetadataPreload() { |
|
return true; |
|
} |
|
|
|
@Override |
|
public Optional<SchemaCacheValue> initSchema() { |
|
String remoteDbName = ((ExternalDatabase<?>) this.getDatabase()).getRemoteName(); |
|
// A missing table pattern makes JDBC enumerate every table in the database, so honor the |
|
// effective-name fallback before any metadata or identifier-mapping call. |
|
String remoteTableName = getRemoteName(); |
|
if (DebugPointUtil.isEnable("JdbcExternalTable.initSchema.sleep")) { |
|
long sleepMs = DebugPointUtil.getDebugParamOrDefault( |
|
"JdbcExternalTable.initSchema.sleep", "sleepMs", 0L); |
|
if (sleepMs > 0) { |
|
LOG.info("debug point JdbcExternalTable.initSchema.sleep hit for {}.{}, sleep {}ms", |
|
remoteDbName, remoteTableName, sleepMs); |
|
try { |
|
Thread.sleep(sleepMs); |
|
} catch (InterruptedException ignore) { |
|
Thread.currentThread().interrupt(); |
|
} |
|
} |
|
} |
|
|
|
// 1. Retrieve remote column information |
|
List<Column> columns = ((JdbcExternalCatalog) catalog).listColumns(remoteDbName, remoteTableName); |
|
if (columns == null || columns.isEmpty()) { |
|
return Optional.empty(); |
- branch-4.1 preload call:
|
boolean preloadLatestSnapshot = latestOnlyRelation && supportsLatestSnapshot; |
|
// Skip schema and partition warmup for snapshot-aware tables when only non-latest relations are referenced. |
|
boolean preloadSchema = !supportsLatestSnapshot || latestOnlyRelation; |
|
boolean preloadPartition = preloadSchema && table.supportInternalPartitionPruned(); |
|
if (preloadLatestSnapshot) { |
|
statementContext.loadSnapshots(table, Optional.empty(), Optional.empty()); |
|
} |
|
if (preloadSchema) { |
|
table.getBaseSchema(); |
|
} |
- nullable consumer:
|
@Override |
|
public List<Slot> computeOutput() { |
|
IdGenerator<ExprId> exprIdGenerator = StatementScopeIdGenerator.getExprIdGenerator(); |
|
Builder<Slot> slots = ImmutableList.builder(); |
|
table.getBaseSchema() |
|
.stream() |
|
.map(col -> SlotReference.fromColumn(exprIdGenerator.getNextId(), table, col, qualified())) |
|
.forEach(slots::add); |
master
master has migrated JDBC catalogs to PluginDrivenExternalTable and therefore needs a separate fix rather than a mechanical backport.
PluginDrivenExternalTable.initSchema() returns Optional.empty() when the connector table handle is missing.
- With a valid handle but zero returned columns, it creates a present schema cache value containing an empty column list.
ExternalTable.getFullSchema() still maps an empty optional to null, while consumers such as LogicalCatalogRelation assume a non-null list.
The latest metadata-cache refactor in #66633 changes the common default path: the cache loader now converts Optional.empty() into a generic CacheException before getFullSchema() returns null. This normally avoids the exact NPE, but it is not an equivalent JDBC fix:
- the error does not include actionable JDBC catalog and remote table context;
- failed loads are completed exceptionally and are not retained as negative cache entries;
- repeated queries can repeatedly execute remote table-handle or metadata resolution;
- zero-column schemas with a valid handle are still represented as a normal empty schema.
Relevant code:
- plugin-driven schema loader:
|
public boolean supportsExternalMetadataPreload() { |
|
if (!(catalog instanceof PluginDrivenExternalCatalog)) { |
|
return false; |
|
} |
|
// F11: gate async metadata pre-load on the connector-declared SUPPORTS_METADATA_PRELOAD capability |
|
// (replacing the legacy engine-name "jdbc" string, per the iron rule). jdbc and iceberg both declare |
|
// it; connectors not yet validated for concurrent pre-warming (e.g. ES) do not, and fall back to |
|
// synchronous load at binding time. Pure planning/lock-latency optimization, no correctness effect. |
|
Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); |
|
return connector != null |
|
&& connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_METADATA_PRELOAD); |
|
} |
|
|
|
@Override |
|
public Optional<SchemaCacheValue> initSchema() { |
|
PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; |
|
// Keep the JDBC schema delay debug point available for manual regression verification. |
|
if ("jdbc".equalsIgnoreCase(pluginCatalog.getType()) |
|
&& DebugPointUtil.isEnable("PluginDrivenExternalTable.initSchema.sleep")) { |
|
long sleepMs = DebugPointUtil.getDebugParamOrDefault( |
|
"PluginDrivenExternalTable.initSchema.sleep", "sleepMs", 0L); |
|
if (sleepMs > 0) { |
|
LOG.info("debug point PluginDrivenExternalTable.initSchema.sleep hit for {}.{}, sleep {}ms", |
|
db != null ? db.getRemoteName() : "", getRemoteName(), sleepMs); |
|
try { |
|
Thread.sleep(sleepMs); |
|
} catch (InterruptedException ignore) { |
|
Thread.currentThread().interrupt(); |
|
} |
|
} |
|
} |
|
Connector connector = pluginCatalog.getConnector(); |
|
ConnectorSession session = pluginCatalog.buildCrossStatementSession(); |
|
try { |
|
ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); |
|
String dbName = db != null ? db.getRemoteName() : ""; |
|
String tableName = getRemoteName(); |
|
if (isView()) { |
|
// A connector view has no table handle (the SDK tableExists() is false for views); build the schema |
|
// from the view definition's columns instead. Mirrors legacy IcebergUtils.loadViewSchemaCacheValue |
|
// (icebergView.schema()). Gated on isView() => only view-supporting connectors (SUPPORTS_VIEW) reach |
|
// here; view-less connectors (jdbc/paimon/maxcompute) keep isView()==false and skip this. |
|
ConnectorViewDefinition viewDefinition = metadata.getViewDefinition(session, dbName, tableName); |
|
ConnectorTableSchema viewSchema = new ConnectorTableSchema( |
|
tableName, viewDefinition.getColumns(), null, Collections.emptyMap()); |
|
return Optional.of(toSchemaCacheValue(metadata, session, dbName, tableName, viewSchema)); |
|
} |
|
Optional<ConnectorTableHandle> handleOpt = resolveConnectorTableHandle(session, metadata); |
|
if (!handleOpt.isPresent()) { |
|
LOG.warn("Table handle not found for plugin-driven table: {}.{}", dbName, tableName); |
|
return Optional.empty(); |
|
} |
|
|
|
ConnectorTableSchema tableSchema = metadata.getTableSchema(session, handleOpt.get()); |
|
return Optional.of(toSchemaCacheValue(metadata, session, dbName, tableName, tableSchema)); |
- nullable base implementation:
|
@Override |
|
public List<Column> getFullSchema() { |
|
// NOT getFullSchema(Optional.empty()): an empty snapshot means "this reference has no pin" (=> |
|
// latest), whereas the no-arg form means "I have no reference, resolve from the ambient context". |
|
// Collapsing the two would strip the ambient resolution from every statement-global caller. |
|
Optional<SchemaCacheValue> schemaCacheValue = getSchemaCacheValue(); |
|
return schemaCacheValue.map(SchemaCacheValue::getSchema).orElse(null); |
|
} |
|
|
|
/** |
|
* The full schema AS OF {@code snapshot}. See {@link #getSchemaCacheValue(Optional)} for why the plan |
|
* path must pass the reference's pin rather than relying on the ambient lookup. |
|
*/ |
|
public List<Column> getFullSchema(Optional<MvccSnapshot> snapshot) { |
|
Optional<SchemaCacheValue> schemaCacheValue = getSchemaCacheValue(snapshot); |
|
return schemaCacheValue.map(SchemaCacheValue::getSchema).orElse(null); |
- generic cache exception:
|
private SchemaCacheValue loadSchemaCacheValue(SchemaCacheKey key) { |
|
CatalogIf<?> catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(key.getNameMapping().getCtlId()); |
|
if (!(catalog instanceof ExternalCatalog)) { |
|
throw new CacheException("catalog %s is not external when loading schema cache", |
|
null, key.getNameMapping().getCtlId()); |
|
} |
|
ExternalCatalog externalCatalog = (ExternalCatalog) catalog; |
|
return externalCatalog.getSchema(key).orElseThrow(() -> new CacheException( |
|
"failed to load schema cache value for: %s.%s.%s", |
|
null, key.getNameMapping().getCtlId(), |
|
key.getNameMapping().getLocalDbName(), |
|
key.getNameMapping().getLocalTblName())); |
- non-null consumer assumption:
|
@Override |
|
public List<Slot> computeOutput() { |
|
IdGenerator<ExprId> exprIdGenerator = StatementScopeIdGenerator.getExprIdGenerator(); |
|
Builder<Slot> slots = ImmutableList.builder(); |
|
table.getBaseSchema() |
|
.stream() |
|
.map(col -> SlotReference.fromColumn(exprIdGenerator.getNextId(), table, col, qualified())) |
|
.forEach(slots::add); |
What did you expect?
When JDBC schema metadata cannot be resolved:
- Query analysis should fail with a deterministic, actionable exception containing the catalog and remote database/table name.
- No planner path should expose a
null schema to callers that require a schema list.
- A persistent metadata failure should not cause every repeated query to reconnect to or reload metadata from the remote JDBC source.
- Preload-enabled and preload-disabled queries should have the same error semantics.
- The generic preload rule should not invalidate the table schema cache on every failure.
How to reproduce
Possible reproduction conditions include a JDBC driver returning no rows from DatabaseMetaData.getColumns(), insufficient metadata permission, or a remote table being removed while Doris still has a table-name entry.
For branch-4.1:
- Create a JDBC catalog and make one remote table visible through table listing.
- Make the column metadata lookup return an empty result.
- Enable
enable_preload_external_metadata.
- Run a Nereids mixed query joining an internal Doris table and the JDBC table.
- Observe that preload reads the empty schema and later planning throws the
getBaseSchema().stream() NPE.
- Repeat with preload disabled; the same nullable schema can fail during normal relation output computation.
For branch-4.0, run the JDBC query without the preload-specific steps.
For master, make JdbcConnectorMetadata.getTableHandle() return empty, or return a valid handle with an empty ConnectorTableSchema, then repeat the query and inspect both the exception and the number of remote metadata calls.
Suggested fix and tests
For branch-4.0 and branch-4.1:
- preserve
Optional.empty() in the existing schema cache as a negative entry;
- reject the missing schema at the JDBC table schema-consumption boundary with an actionable
JdbcClientException;
- cover both
getFullSchema() and inherited getBaseSchema() paths;
- keep
PreloadExternalMetadata generic and unchanged.
For master, adapt the same contract to PluginDrivenExternalTable and the unified metadata cache, preferably with an explicit missing-schema result or bounded negative-cache representation rather than uncached loader exceptions.
Tests should cover:
- null and empty JDBC column metadata;
- missing connector table handle;
- preload enabled and disabled;
- JDBC-only and mixed internal/JDBC queries;
- repeated reads do not repeat remote metadata access while a negative entry is valid;
- normal non-empty schemas remain unchanged.
Anything else?
This report distinguishes the exact NPE on branch-4.x from the current master behavior after #66633. The underlying cross-version issue is the lack of a consistent JDBC missing-schema contract and bounded negative caching.
Are you willing to submit PR?
Code of Conduct
Search before asking
Version
Verified against the following upstream heads on 2026-09-01:
branch-4.0:8a9961723ea4be00cdf923c60759607202c7e2e7branch-4.1:6f4c6a4be42ab3f4e1811982703d4b71b5a8ea3cmaster:feb9e04f78490296c3393cbd594aef617af6b433Related preload PRs:
master)branch-4.1backport)What is wrong?
JDBC external tables do not have a consistent, cache-safe failure contract when remote metadata returns no columns or the remote table handle can no longer be resolved.
branch-4.0 and branch-4.1
The failure chain is:
JdbcExternalTable.initSchema()returnsOptional.empty()whenlistColumns()returnsnullor an empty list.ExternalTable.getFullSchema()maps the empty value tonull.getBaseSchema()also returnsnull.LogicalCatalogRelation.computeOutput()callstable.getBaseSchema().stream()and throws a null pointer exception.Typical error:
branch-4.1additionally supports JDBC metadata preload through #64579. Whenenable_preload_external_metadata=trueand a mixed query contains both an internal table requiring a plan-time read lock and a JDBC table,PreloadExternalMetadatacallsgetBaseSchema()before locking but does not validate its result. Analysis later reaches the samestream()NPE. Disabling preload does not remove the underlying bug; it only changes when the schema is loaded.branch-4.0does not contain the preload feature, but regular JDBC query planning still has the same nullable-schema chain.Relevant code:
doris/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/JdbcExternalTable.java
Lines 129 to 136 in 8a99617
doris/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java
Lines 175 to 184 in 8a99617
doris/fe/fe-core/src/main/java/org/apache/doris/datasource/jdbc/JdbcExternalTable.java
Lines 130 to 157 in 6f4c6a4
doris/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/PreloadExternalMetadata.java
Lines 102 to 111 in 6f4c6a4
doris/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalCatalogRelation.java
Lines 134 to 141 in 6f4c6a4
master
masterhas migrated JDBC catalogs toPluginDrivenExternalTableand therefore needs a separate fix rather than a mechanical backport.PluginDrivenExternalTable.initSchema()returnsOptional.empty()when the connector table handle is missing.ExternalTable.getFullSchema()still maps an empty optional tonull, while consumers such asLogicalCatalogRelationassume a non-null list.The latest metadata-cache refactor in #66633 changes the common default path: the cache loader now converts
Optional.empty()into a genericCacheExceptionbeforegetFullSchema()returnsnull. This normally avoids the exact NPE, but it is not an equivalent JDBC fix:Relevant code:
doris/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java
Lines 459 to 513 in feb9e04
doris/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java
Lines 180 to 195 in feb9e04
doris/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java
Lines 524 to 535 in feb9e04
doris/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalCatalogRelation.java
Lines 152 to 159 in feb9e04
What did you expect?
When JDBC schema metadata cannot be resolved:
nullschema to callers that require a schema list.How to reproduce
Possible reproduction conditions include a JDBC driver returning no rows from
DatabaseMetaData.getColumns(), insufficient metadata permission, or a remote table being removed while Doris still has a table-name entry.For
branch-4.1:enable_preload_external_metadata.getBaseSchema().stream()NPE.For
branch-4.0, run the JDBC query without the preload-specific steps.For
master, makeJdbcConnectorMetadata.getTableHandle()return empty, or return a valid handle with an emptyConnectorTableSchema, then repeat the query and inspect both the exception and the number of remote metadata calls.Suggested fix and tests
For
branch-4.0andbranch-4.1:Optional.empty()in the existing schema cache as a negative entry;JdbcClientException;getFullSchema()and inheritedgetBaseSchema()paths;PreloadExternalMetadatageneric and unchanged.For
master, adapt the same contract toPluginDrivenExternalTableand the unified metadata cache, preferably with an explicit missing-schema result or bounded negative-cache representation rather than uncached loader exceptions.Tests should cover:
Anything else?
This report distinguishes the exact NPE on
branch-4.xfrom the currentmasterbehavior after #66633. The underlying cross-version issue is the lack of a consistent JDBC missing-schema contract and bounded negative caching.Are you willing to submit PR?
Code of Conduct