Skip to content

[Bug] Reject ambiguous external metadata local-name mappings #66233

Description

@wenzhenghu

Search before asking

I searched existing Apache Doris issues for duplicate external metadata local names, meta_names_mapping conflicts, and ambiguous remote-to-local name resolution. I did not find an issue covering this exact gap.

Version

Apache Doris master at 2215dc476a1f9a1f6edba8c7a056e450165f7001.

This problem was found while reviewing #65126, but the ambiguous mapping state is already possible on the master baseline and was not introduced by that PR.

What is wrong?

External metadata name loading does not unconditionally enforce that one exact local database/table name identifies only one remote object.

For example, a custom naming hook or mapping can produce:

RemoteA -> LocalX
RemoteB -> LocalX

This state is not resolvable: SQL can reference only LocalX, while the object cache key and generated Doris metadata ID are also derived from LocalX.

The generic conflict checks currently run only under selected case-insensitive/lower-case modes. In ordinary mode 0, a custom connector naming hook or an uncovered mapping collision can still produce the same exact local name for multiple remote objects.

Master baseline behavior

The legacy MetaCache keeps the complete remote/local pairs in a mutable list.

  • MetaCache.listNames() returns both local entries, so database enumeration can expose duplicate LocalX values.
  • MetaCache.getRemoteName(LocalX) uses findFirst(), so only RemoteA is selected.
  • The metadata object cache is keyed only by LocalX, so RemoteB cannot be addressed independently.
  • Incremental updateCache() appends another pair without replacing the existing local identity, making the result event-order dependent.

Relevant code:

  • public List<String> listNames() {
    return Objects.requireNonNull(namesCache.get("")).stream().map(Pair::value).collect(Collectors.toList());
    }
    public String getRemoteName(String localName) {
    return Objects.requireNonNull(namesCache.getIfPresent("")).stream()
    .filter(pair -> pair.value().equals(localName))
    .map(Pair::key)
    .findFirst()
    .orElse(null);
  • public void updateCache(String remoteName, String localName, T obj, long id) {
    metaObjCache.put(localName, Optional.of(obj));
    namesCache.asMap().compute("", (k, v) -> {
    if (v == null) {
    return Lists.newArrayList(Pair.of(remoteName, localName));
    } else {
    v.add(Pair.of(remoteName, localName));
    return v;
    }
    });
    idToName.put(id, localName);
  • // Check for conflicts when stored table names or meta names are case-insensitive
    if (Boolean.parseBoolean(extCatalog.getLowerCaseMetaNames())
    || this.isStoredTableNamesLowerCase()
    || this.isTableNamesCaseInsensitive()) {
    // Map to track lowercased local names and their corresponding remote names
    Map<String, List<String>> lowerCaseToRemoteNames = Maps.newHashMap();
    // Collect lowercased local names and their remote counterparts
    for (Pair<String, String> pair : tableNames) {
    String lowerCaseLocalName = pair.value().toLowerCase();
    lowerCaseToRemoteNames.computeIfAbsent(lowerCaseLocalName, k -> Lists.newArrayList()).add(pair.key());
    }
    // Identify conflicts: multiple remote names mapping to the same lowercased local name
    List<String> conflicts = lowerCaseToRemoteNames.values().stream()
    .filter(remoteNames -> remoteNames.size() > 1) // Conflict: more than one remote name
    .flatMap(List::stream) // Collect all conflicting remote names
    .collect(Collectors.toList());
    // Throw exception if conflicts are found
    if (!conflicts.isEmpty()) {
    throw new RuntimeException(String.format(
    ExternalCatalog.FOUND_CONFLICTING + " table names under case-insensitive conditions. "
    + "Conflicting remote table names: %s in remote database '%s' under catalog '%s'. "
    + "Please use meta_names_mapping to handle name mapping.",
    String.join(", ", conflicts), remoteName, extCatalog.getName()));
    }
    }
  • // Check for conflicts when lower_case_meta_names = true or lower_case_database_names = 2
    if (Boolean.parseBoolean(getLowerCaseMetaNames()) || getLowerCaseDatabaseNames() == 2) {
    // Map to track lowercase local names and their corresponding remote names
    Map<String, List<String>> lowerCaseToRemoteNames = Maps.newHashMap();
    // Collect lowercased local names and their remote counterparts
    for (Pair<String, String> pair : remoteToLocalPairs) {
    String lowerCaseLocalName = pair.second.toLowerCase();
    lowerCaseToRemoteNames.computeIfAbsent(lowerCaseLocalName, k -> Lists.newArrayList()).add(pair.first);
    }
    // Identify conflicts: multiple remote names mapping to the same lowercase local name
    List<String> conflicts = lowerCaseToRemoteNames.values().stream()
    .filter(remoteNames -> remoteNames.size() > 1) // Conflict: more than one remote name
    .flatMap(List::stream) // Collect all conflicting remote names
    .collect(Collectors.toList());
    // Throw exception if conflicts are found
    if (!conflicts.isEmpty()) {
    throw new RuntimeException(String.format(
    FOUND_CONFLICTING + " database names under case-insensitive conditions. "
    + "Conflicting remote database names: %s in catalog %s. "
    + "Please use meta_names_mapping to handle name mapping.",
    String.join(", ", conflicts), name));
    }
    }

Impact

The trigger is narrow because normal remote metadata normally has unique names and standard mapping implementations reject many direct conflicts. The remaining gap mainly affects custom connector naming hooks, general ExternalCatalog subclasses, or uncovered mode-0 mapping collisions.

Once triggered, the impact is a correctness problem:

  • one remote database/table becomes inaccessible or is resolved through the wrong remote identity
  • cached and cache-bypass paths may select different remote objects
  • object-cache keys and deterministic IDs collide
  • database enumeration may contain duplicate local names
  • incremental events may change the selected remote object according to event order

Expected behavior

Within one catalog scope for databases, and within one database scope for tables:

  • an exact local name must identify at most one remote object
  • two different remote names mapping to the same exact local name must fail with a clear conflict error
  • case-sensitive mode 0 must continue to allow distinct local names such as Foo and foo
  • mode-specific case-insensitive conflict checks must remain in effect
  • duplicate/replayed incremental events must remain idempotent

Suggested fix

  1. Validate the exact local-name uniqueness invariant when building the complete external metadata names snapshot.
  2. Report both conflicting remote names and the shared local name.
  3. Add a defensive invariant in the shared names value abstraction so custom loaders cannot publish an ambiguous snapshot.
  4. Keep mode-aware case-insensitive collision validation in the catalog/database loader.
  5. Add database and table tests, including a custom naming hook in mode 0.

PR #65126 plans to fix this by validating the invariant in its immutable NameCacheValue snapshot before publication.

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