Skip to content

[Bug] JDBC missing schema handling may cause NPE or repeated metadata reloads #67365

Description

@wenzhenghu

Search before asking

  • I searched the existing issues and found no similar issue.

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:

  1. JdbcExternalTable.initSchema() returns Optional.empty() when listColumns() returns null or an empty list.
  2. The external schema cache stores the negative result.
  3. ExternalTable.getFullSchema() maps the empty value to null.
  4. getBaseSchema() also returns null.
  5. 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:

  1. Query analysis should fail with a deterministic, actionable exception containing the catalog and remote database/table name.
  2. No planner path should expose a null schema to callers that require a schema list.
  3. A persistent metadata failure should not cause every repeated query to reconnect to or reload metadata from the remote JDBC source.
  4. Preload-enabled and preload-disabled queries should have the same error semantics.
  5. 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:

  1. Create a JDBC catalog and make one remote table visible through table listing.
  2. Make the column metadata lookup return an empty result.
  3. Enable enable_preload_external_metadata.
  4. Run a Nereids mixed query joining an internal Doris table and the JDBC table.
  5. Observe that preload reads the empty schema and later planning throws the getBaseSchema().stream() NPE.
  6. 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?

  • Yes, I am willing to submit a PR.

Code of Conduct

  • I agree to follow this projects Code of Conduct.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions