Skip to content

[Bug] HMS incremental events have multiple metadata cache consistency gaps #65750

Description

@wenzhenghu

Search before asking

I searched existing Apache Doris issues and did not find an issue covering these HMS incremental-event cache consistency problems.

Version

Apache Doris master at 3a75387e61388bd886eeef38b794d5ed4eb298bf.

These problems were found while reviewing #65126, but they are already present on the current master baseline and were not introduced by that PR.

What's Wrong?

There are five independent correctness gaps in HMS incremental event handling.

1. ALTER TABLE/ALTER DATABASE rename can be incorrectly cancelled by a load-through "local existence" check

Status: Fixed by the current revision of #65126 (pending merge).

AlterTableEvent.processRename() calls externalTableExistInLocal() before applying the rename:

private void processRename() throws DdlException {
if (!isRename) {
return;
}
boolean hasExist = Env.getCurrentEnv().getCatalogMgr()
.externalTableExistInLocal(tableAfter.getDbName(), tableAfter.getTableName(), catalogName);
if (hasExist) {
logInfo("AlterExternalTable canceled,because tableAfter has exist, "
+ "catalogName:[{}],dbName:[{}],tableName:[{}]",
catalogName, dbName, tableAfter.getTableName());
return;
}
Env.getCurrentEnv().getCatalogMgr()
.unregisterExternalTable(tableBefore.getDbName(), tableBefore.getTableName(), catalogName, true);
Env.getCurrentEnv().getCatalogMgr()
.registerExternalTableFromEvent(
tableAfter.getDbName(), tableAfter.getTableName(), catalogName, eventTime, true);

However, HMSExternalCatalog.tableExistInLocal() eventually calls the normal load-through table lookup rather than a cache-only lookup:

@Override
public boolean tableExistInLocal(String dbName, String tblName) {
makeSureInitialized();
HMSExternalDatabase hmsExternalDatabase = (HMSExternalDatabase) getDbNullable(dbName);
if (hmsExternalDatabase == null) {
return false;
}
return hmsExternalDatabase.getTable(tblName).isPresent();

After HMS has renamed old_table to new_table, querying new_table from this check can load it from HMS and return true. The event is then cancelled, and cached state for old_table may remain. AlterDatabaseEvent.processRename() has the same structural problem through catalog.getDbNullable(dbAfter.getName()), although database rename is less commonly reachable in standard Hive.

The current revision of #65126 removes the load-through target-existence checks and makes both rename handlers converge local state by unregistering the old identity and registering the new identity. Its focused FE unit tests cover cold and already-hot rename targets.

2. ADD/DROP PARTITION events do not fence an in-flight partition-values load, and DROP may skip lower-level cache invalidation

Both addPartitionsCache() and dropPartitionsCache() return immediately when the partition-values entry is absent:

private void addPartitionsCache(NameMapping nameMapping,
List<String> partitionNames,
List<Type> partitionColumnTypes) {
long catalogId = nameMapping.getCtlId();
MetaCacheEntry<PartitionValueCacheKey, HivePartitionValues> partitionValuesEntry =
partitionValuesEntryIfInitialized(catalogId);
if (partitionValuesEntry == null) {
return;
}
PartitionValueCacheKey key = new PartitionValueCacheKey(nameMapping, partitionColumnTypes);
HivePartitionValues partitionValues = partitionValuesEntry.getIfPresent(key);
if (partitionValues == null) {
return;
}
HivePartitionValues copy = partitionValues.copy();
Map<String, PartitionItem> nameToPartitionItem = copy.getNameToPartitionItem();
Map<String, List<String>> nameToPartitionValues = copy.getNameToPartitionValues();
HMSExternalCatalog catalog = hmsCatalog(catalogId);
String localTblName = nameMapping.getLocalTblName();
for (String partitionName : partitionNames) {
if (nameToPartitionItem.containsKey(partitionName)) {
LOG.info("addPartitionsCache partitionName:[{}] has exist in table:[{}]",
partitionName, localTblName);
continue;
}
ListPartitionItem listPartitionItem = toListPartitionItem(partitionName, key.types, catalog.getName());
nameToPartitionItem.put(partitionName, listPartitionItem);
nameToPartitionValues.put(partitionName, HiveUtil.toPartitionValues(partitionName));
}
copy.rebuildSortedPartitionRanges();
HivePartitionValues partitionValuesCur = partitionValuesEntry.getIfPresent(key);
if (partitionValuesCur == partitionValues) {
partitionValuesEntry.put(key, copy);
}
}

private void dropPartitionsCache(ExternalTable dorisTable,
List<String> partitionNames,
boolean invalidPartitionCache) {
NameMapping nameMapping = dorisTable.getOrBuildNameMapping();
long catalogId = nameMapping.getCtlId();
MetaCacheEntry<PartitionValueCacheKey, HivePartitionValues> partitionValuesEntry =
partitionValuesEntryIfInitialized(catalogId);
if (partitionValuesEntry == null) {
return;
}
PartitionValueCacheKey key = new PartitionValueCacheKey(nameMapping, null);
HivePartitionValues partitionValues = partitionValuesEntry.getIfPresent(key);
if (partitionValues == null) {
return;
}
HivePartitionValues copy = partitionValues.copy();
Map<String, PartitionItem> nameToPartitionItem = copy.getNameToPartitionItem();
Map<String, List<String>> nameToPartitionValues = copy.getNameToPartitionValues();
for (String partitionName : partitionNames) {
if (!nameToPartitionItem.containsKey(partitionName)) {
LOG.info("dropPartitionsCache partitionName:[{}] not exist in table:[{}]",
partitionName, nameMapping.getFullLocalName());
continue;
}
nameToPartitionItem.remove(partitionName);
nameToPartitionValues.remove(partitionName);
if (invalidPartitionCache) {
invalidatePartitionCache(nameMapping, partitionName);
}
}
copy.rebuildSortedPartitionRanges();
HivePartitionValues partitionValuesCur = partitionValuesEntry.getIfPresent(key);
if (partitionValuesCur == partitionValues) {
partitionValuesEntry.put(key, copy);
}
}

This causes two problems:

  • If a partition-values load started before the event, the event does not invalidate/bump the entry, so the pre-event snapshot may still be published afterward.
  • If the partition-values entry has been evicted while partition metadata or file listings remain cached, a DROP PARTITION event returns before invalidating those lower-level entries.

The second case can expose stale data when a partition name is later recreated with a different path or files. It also prevents the cache-miss fallback added by #65334 from being reached through this DROP event path.

3. Incremental CREATE_DATABASE/CREATE_TABLE events can bypass include/exclude visibility filters

Full names loading applies include_database_list, exclude_database_list, and include_table_list:

private List<Pair<String, String>> getFilteredDatabaseNames() {
List<String> allDatabases = Lists.newArrayList(listDatabaseNames());
allDatabases.remove(InfoSchemaDb.DATABASE_NAME);
allDatabases.add(InfoSchemaDb.DATABASE_NAME);
allDatabases.remove(MysqlDb.DATABASE_NAME);
allDatabases.add(MysqlDb.DATABASE_NAME);
Map<String, Boolean> includeDatabaseMap = getIncludeDatabaseMap();
Map<String, Boolean> excludeDatabaseMap = getExcludeDatabaseMap();
lowerCaseToDatabaseName.clear();
List<Pair<String, String>> remoteToLocalPairs = Lists.newArrayList();
allDatabases = allDatabases.stream().filter(dbName -> {
if (!dbName.equals(InfoSchemaDb.DATABASE_NAME) && !dbName.equals(MysqlDb.DATABASE_NAME)) {
// Exclude database map take effect with higher priority over include database map
if (!excludeDatabaseMap.isEmpty() && excludeDatabaseMap.containsKey(dbName)) {
return false;
}
if (!includeDatabaseMap.isEmpty() && !includeDatabaseMap.containsKey(dbName)) {
return false;
}
}
return true;
}).collect(Collectors.toList());
for (String remoteDbName : allDatabases) {
String localDbName = fromRemoteDatabaseName(remoteDbName);
// Populate lowercase mapping for case-insensitive lookups
lowerCaseToDatabaseName.put(remoteDbName.toLowerCase(), remoteDbName);
// Apply lower_case_database_names mode to local name
int dbNameMode = getLowerCaseDatabaseNames();
if (dbNameMode == 1) {
localDbName = localDbName.toLowerCase();
} else if (dbNameMode == 2) {
// Mode 2: preserve original remote case for display
localDbName = remoteDbName;
}
remoteToLocalPairs.add(Pair.of(remoteDbName, localDbName));
}
// 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));
}

* @return names of tables in specified database, filtered by include_table_list if configured
*/
public final List<String> listTableNames(SessionContext ctx, String dbName) {
makeSureInitialized();
Map<String, List<String>> includeTableMap = getIncludeTableMap();
if (includeTableMap.containsKey(dbName) && !includeTableMap.get(dbName).isEmpty()) {
if (LOG.isDebugEnabled()) {
LOG.debug("get table list from include map. catalog: {}, db: {}, tables: {}",
name, dbName, includeTableMap.get(dbName));
}
return includeTableMap.get(dbName);
}
return listTableNamesFromRemote(ctx, dbName);
}

The incremental register paths directly update the legacy metadata cache without applying the same visibility policy:

@Override
public void registerDatabase(long dbId, String dbName) {
if (LOG.isDebugEnabled()) {
LOG.debug("create database [{}]", dbName);
}
ExternalDatabase<? extends ExternalTable> db = buildDbForInit(dbName, null, dbId, logType, false);
if (isInitialized()) {
metaCache.updateCache(db.getRemoteName(), db.getFullName(), db,
Util.genIdByName(name, db.getFullName()));
}

// Only used for sync hive metastore event
@Override
public boolean registerTable(TableIf tableIf) {
makeSureInitialized();
String tableName = tableIf.getName();
if (LOG.isDebugEnabled()) {
LOG.debug("create table [{}]", tableName);
}
if (isInitialized()) {
String localName = extCatalog.fromRemoteTableName(this.remoteName, tableName);
metaCache.updateCache(tableName, localName, (T) tableIf,
Util.genIdByName(extCatalog.getName(), name, localName));
lowerCaseToTableName.put(tableName.toLowerCase(), tableName);
}
setLastUpdateTime(System.currentTimeMillis());

When a names cache is already hot, an HMS CREATE event can therefore add a database/table that should remain hidden. The object can subsequently become queryable through the cached name.

4. DROP_PARTITION records a DATABASE deletion in ExternalMetaIdMgr

DropPartitionEvent.transferToMetaIdMappings() uses META_OBJECT_TYPE_DATABASE instead of META_OBJECT_TYPE_PARTITION:

@Override
protected List<MetaIdMappingsLog.MetaIdMapping> transferToMetaIdMappings() {
List<MetaIdMappingsLog.MetaIdMapping> metaIdMappings = Lists.newArrayList();
for (String partitionName : this.getAllPartitionNames()) {
MetaIdMappingsLog.MetaIdMapping metaIdMapping = new MetaIdMappingsLog.MetaIdMapping(
MetaIdMappingsLog.OPERATION_TYPE_DELETE,
MetaIdMappingsLog.META_OBJECT_TYPE_DATABASE,
dbName, tblName, partitionName);
metaIdMappings.add(metaIdMapping);
}
return ImmutableList.copyOf(metaIdMappings);

ExternalMetaIdMgr interprets this as a request to remove the entire database mapping subtree:

private static void handleDelMetaIdMapping(MetaIdMappingsLog.MetaIdMapping mapping,
CtlMetaIdMgr ctlMetaIdMgr,
MetaIdMappingsLog.MetaObjectType objType) {
TblMetaIdMgr tblMetaIdMgr;
DbMetaIdMgr dbMetaIdMgr;
switch (objType) {
case DATABASE:
ctlMetaIdMgr.dbNameToMgr.remove(mapping.getDbName());
break;
case TABLE:
dbMetaIdMgr = ctlMetaIdMgr.dbNameToMgr.get(mapping.getDbName());
if (dbMetaIdMgr != null) {
dbMetaIdMgr.tblNameToMgr.remove(mapping.getTblName());
}
break;
case PARTITION:
dbMetaIdMgr = ctlMetaIdMgr.dbNameToMgr.get(mapping.getDbName());
if (dbMetaIdMgr != null) {
tblMetaIdMgr = dbMetaIdMgr.tblNameToMgr.get(mapping.getTblName());
if (tblMetaIdMgr != null) {
tblMetaIdMgr.partitionNameToMgr.remove(mapping.getPartitionName());
}
}

Current production use of the ExternalMetaIdMgr lookup APIs is limited, so the immediate query impact appears limited, but the persisted/replayed external metadata ID state is incorrect.

5. ALTER_DATABASE rename can generate an ID from the remote name instead of the canonical local name

Status: Open. This is present on the master baseline and is not introduced by #65126.

CatalogMgr.registerExternalDatabaseFromEvent() generates the event-created database ID directly from the incoming remote dbName:

public void registerExternalDatabaseFromEvent(String dbName, String catalogName)
throws DdlException {
CatalogIf catalog = nameToCatalog.get(catalogName);
if (catalog == null) {
throw new DdlException("No catalog found with name: " + catalogName);
}
if (!(catalog instanceof ExternalCatalog)) {
throw new DdlException("Only support create ExternalCatalog databases");
}
HMSExternalCatalog hmsCatalog = (HMSExternalCatalog) catalog;
long dbId = Util.genIdByName(catalogName, dbName);
// -1L means it will be dropped later, ignore
if (dbId == ExternalMetaIdMgr.META_ID_FOR_NOT_EXISTS) {
return;
}
hmsCatalog.registerDatabase(dbId, dbName);

Normal external-database object loading instead generates the ID from the canonical localDbName:

OptionalLong.of(Config.external_cache_expire_time_seconds_after_access),
OptionalLong.of(Config.external_cache_refresh_time_minutes * 60L),
Math.max(Config.max_meta_object_cache_num, 1),
ignored -> getFilteredDatabaseNames(),
localDbName -> Optional.ofNullable(
buildDbForInit(null, localDbName, Util.genIdByName(name, localDbName), logType,
true)),
(key, value, cause) -> value.ifPresent(v -> v.resetMetaToUninitialized()));

When lower_case_database_names = 1, or more generally whenever the incoming remote name differs from its canonical local cache key, the same database can therefore have two deterministic IDs:

  • Immediately after the event: ID(remote name) -> canonical local name.
  • After cache reset or normal reload: ID(canonical local name) -> canonical local name.

This makes the database ID and the local ID-to-name index unstable across refresh/reload and can break ID-based lookup consistency.

What You Expected?

  • Rename events should inspect only locally published cache state and should always remove the old local name. This is fixed by the current revision of [refactor](fe) Refactor external metadata cache with MetaCacheEntry #65126.
  • Partition events should prevent pre-event snapshots from being published and should invalidate partition/file caches even when partition-values cache state is cold.
  • Incremental create events should apply the same visibility rules as full names loading.
  • DROP_PARTITION should delete only the corresponding partition metadata-ID mapping.
  • Incremental database registration should derive the database ID from the same canonical local name used by normal object loading.

How to Reproduce?

Rename cancellation (fixed by the current revision of #65126)

  1. Enable HMS incremental event synchronization.
  2. Ensure the target name is not locally cached.
  3. Rename old_table to new_table in Hive.
  4. Process the ALTER_TABLE event.
  5. On the affected baseline, the existence check loads new_table from HMS and cancels the event; cached old_table state may remain.

Partition cache

  1. Cache partition metadata/file listings for p=1.
  2. Evict or invalidate only the table's partition-values entry, or block an in-flight partition-values load.
  3. Drop p=1 through HMS and process the DROP_PARTITION event.
  4. Recreate p=1 with a different location/files.
  5. The old partition metadata or file listing may still be reused.

Visibility filter

  1. Create an HMS catalog with an include/exclude database filter or include_table_list.
  2. Warm the corresponding names cache.
  3. Create a filtered-out database/table directly in Hive.
  4. Process the HMS CREATE event.
  5. Observe that the filtered object is inserted into the cached names and becomes visible/queryable.

External metadata ID mapping

  1. Add a database/table/partition mapping to ExternalMetaIdMgr.
  2. Process the partition's DROP_PARTITION mapping.
  3. Observe that the database mapping subtree is removed instead of only the partition mapping.

Database ID stability

  1. Configure an HMS catalog with lower_case_database_names = 1.
  2. Process an ALTER_DATABASE rename event whose new remote name contains uppercase characters.
  3. Observe that registerExternalDatabaseFromEvent() generates the new ID from the remote mixed-case name while the local cache key is lowercase.
  4. Reset or reload the catalog so the normal database-object loader runs.
  5. Observe that the reloaded database ID is generated from the lowercase local name and differs from the event-created ID.

Suggested Fixes

  1. Fixed in the current revision of [refactor](fe) Refactor external metadata cache with MetaCacheEntry #65126: remove the load-through rename target-existence checks and always converge the old/new local identities.
  2. For ADD/DROP PARTITION, invalidate or bump the partition-values key when it is cold/loading. DROP should unconditionally invalidate partition metadata/file cache entries for each dropped partition before optionally updating a hot partition-values snapshot.
  3. Reuse a shared database/table visibility predicate in both full names loading and incremental registration.
  4. Change the DROP_PARTITION mapping type to META_OBJECT_TYPE_PARTITION and add an event mapping unit test.
  5. Resolve the canonical local database name with a cache-only helper, and use it consistently for event registration, invalidation, and Util.genIdByName(). Add mode-1 coverage that asserts the event-created ID equals the normal reload ID.

The remaining four open fixes can be implemented as separate PRs if preferred.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions