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 four 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

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.

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.

What You Expected?

  • Rename events should inspect only locally published cache state and should always remove the old local name.
  • 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.

How to Reproduce?

Rename

  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. Observe that 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.

Suggested Fixes

  1. Introduce a cache-only local-existence check for rename handling. Always invalidate the old name; only skip publishing the new object when it is already locally present.
  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.

The four 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