[refactor](fe) Refactor external metadata cache with MetaCacheEntry#65126
[refactor](fe) Refactor external metadata cache with MetaCacheEntry#65126wenzhenghu wants to merge 46 commits into
Conversation
|
Thank you for your contribution to Apache Doris. Please clearly describe your PR:
|
|
run buildall |
1 similar comment
|
run buildall |
TPC-H: Total hot run time: 29809 ms |
TPC-DS: Total hot run time: 173774 ms |
ClickBench: Total hot run time: 25.22 s |
FE UT Coverage ReportIncrement line coverage |
FE UT Coverage ReportIncrement line coverage |
TPC-H: Total hot run time: 29829 ms |
TPC-DS: Total hot run time: 173258 ms |
ClickBench: Total hot run time: 25.27 s |
FE Regression Coverage ReportIncrement line coverage |
|
run buildall |
FE UT Coverage ReportIncrement line coverage |
FE Regression Coverage ReportIncrement line coverage |
|
run buildall |
|
workbuddy + glm 5.2: 一、总体评价这是一个高质量的外部元数据缓存重构。将旧的 二、架构与优点1. 缓存条目独立化 2. 慢 I/O 不阻塞 Caffeine 同步 load 路径 3. 三层并发协调机制设计正确
4. replay 非阻塞语义 5. lower-case mode 0/1/2 行为正确 6. NameCacheValue 不可变 + copy-on-write 7. 旧代码完全清理 三、问题与建议[Minor] 第二次 generation 检查为 test-only 残留
putLoadedValueWithoutGenerationBump(key, loaded);
if (generation != generationOf(key)) { // publishLock 持有期间不可能变
removeLoadedValueWithoutGenerationBump(key, loaded);
}此时已持有 建议:加注释说明此分支为 test-only 竞态模拟支持,避免被误判为防御式编程。或考虑将竞态验证收敛到测试子类内部,移除生产路径的冗余检查(符合 AGENTS.md "不做防御式编程"原则)。 [Nit] NameCacheValue.names() 每次深拷贝
[Nit] stats() 混合统计源
[Nit] Constructor 过载较多
四、测试覆盖评估测试覆盖非常充分,共 69 个单测 + 2 个回归测试:
核心竞态场景(invalidate vs in-flight load、put vs concurrent miss、refresh vs invalidate)均有对应单测。 五、核心结论:
|
|
workbuddy + hy3: 核心结论:Approve(无 blocking issue,附 minor 跟进)设计亮点(正面)
需关注/跟进的点
测试覆盖充分: |
|
workbuddy + kimi 2.7-code: 核心结论
其余为 minor:mode 2 + meta-names 映射的 key 不一致(历史遗留)、 总体评价:Approve(建议合入,须处理 gsonPostProcess id 映射回填)。 |
morrySnow
left a comment
There was a problem hiding this comment.
Code Review Summary
This PR refactors the external metadata cache from a monolithic MetaCache\<T\> wrapper into independent MetaCacheEntry instances with per-stripe generation-based concurrency control, a new NameCacheValue immutable snapshot, and centralized name-miss-refresh logic. The architecture is a substantial improvement in concurrency correctness. Below are the findings ranked by severity.
Finding 1: Mode-2 case-insensitive replay may fail after names-only invalidation
ExternalDatabase.java: getTableForReplay(String) (line ~735) and ExternalCatalog.java: getDbForReplay(String) (line ~266)
The old code stored the lower-case→original-case index in a separate ConcurrentHashMap that survived resetMetaCacheNames() (names-only invalidation). The new code embeds this index inside NameCacheValue, which is a Caffeine-cached snapshot. After resetMetaCacheNames() invalidates the names entry, getTableForReplay(String) fails to resolve case-insensitive names because resolveTableNameFromSnapshot(... isReplay=true) returns null without reloading. The exact-key check at line 717 (tables.getIfPresent(tblName)) provides partial protection but does NOT help when the replay caller passes a differently-cased name (e.g., "mytable" for cached key "MyTable"). Same issue exists at the database level.
Severity: Medium — requires concurrent names invalidation during replay in mode-2 catalogs.
Finding 2: NameCacheValue.remoteNameOfLocalName() is O(n) linear scan
NameCacheValue.java line 1838
Every local-to-remote name resolution scans all name pairs linearly. For catalogs with 10000+ databases/tables, this becomes expensive on hot paths like buildDbForInit() and buildTableForInit(). The constructor already builds a lowerCaseToRemoteName map for case-insensitive lookup; building a parallel localName→remoteName map would make this O(1).
Finding 3: NameCacheValue.containsLocalName() is O(n) linear scan
NameCacheValue.java line 1847
Similar to Finding 2 — a HashSet\<String\> of local names built during construction would give O(1) existence checks.
Finding 4: NameCacheValue.names() allocates N new Pair objects on every call
NameCacheValue.java line 1833
The names() method deep-copies all pairs via copyPairs() on every invocation. Called from listLocalDatabaseNamesFromCache() which runs on every getDbNames() / SHOW DATABASES / SHOW TABLES. The internal field is already an ImmutableList of already-copied Pairs — the second deep copy is wasteful for hot-path read operations.
Finding 5: invalidateAll() iterates per-key with per-key lock acquisition
MetaCacheEntry.java line 1606
The new invalidateAll() calls bumpAllGenerations() then iterates all keys calling invalidateKey() per key (each acquiring publishLock and bumping the stripe generation again — double bump). The old code used Caffeine's invalidateAll() which was O(1) from the caller's perspective. For caches with max_meta_object_cache_num=1000 entries, this means 1000 lock acquisitions with per-stripe contention.
Finding 6: Duplicated resolve/update/invalidation logic across ExternalCatalog and ExternalDatabase
ExternalCatalog.java lines 360-455, ExternalDatabase.java lines 829-916
Seven method pairs are structurally identical across the two classes: resolve*NameFromSnapshot(), get*NamesValue(), listLocal*NamesFromCache(), getRemote*Name(), update*Cache(), invalidate*Cache(), invalidateAll*Cache(). ~150 lines duplicated. Any future behavioral change to the miss-refresh policy or incremental update semantics must be applied in two places. Consider extracting a shared helper parameterized over the names/objects/ID fields.
Finding 7: Double generation bump in invalidateAll() and invalidateIf()
MetaCacheEntry.java lines 1612, 1639
invalidateAll() calls bumpAllGenerations() (which increments every stripe) and then invalidateKey() calls bumpGeneration(key) (which increments that stripe again). Each key's stripe generation is incremented twice — once by the bulk bump and once by the per-key bump. This overcounts invalidations and wastes atomic operations.
Overall assessment: The concurrency model improvements (per-stripe generations, publish locks, generation-aware refresh) are well-designed and address real correctness issues in the legacy code. The main concerns are: (1) mode-2 replay resilience after names invalidation, (2) O(n) scans in NameCacheValue on hot paths, and (3) significant code duplication between ExternalCatalog and ExternalDatabase. These are addressable without architectural changes.
morrySnow
left a comment
There was a problem hiding this comment.
Code Review — Findings
This PR refactors the external metadata cache to use per-stripe generation-based concurrency control and MetaCacheEntry. The concurrency model is well-designed. Below are findings ranked by severity (inline comments on specific lines).
Correctness (3 findings)
- CONFIRMED: Lost load deduplication — No lock held through slow I/O; concurrent lookups for the same cold key trigger duplicate remote loads.
- CONFIRMED: Mode-2 case-insensitive replay regression — After names-only invalidation, replay lookups fail because the lower-case index moved into the Caffeine-cached snapshot.
- PLAUSIBLE: Race on
getIfPresent-then-compute— ConcurrentinvalidateAll()can leave the names cache empty.
Efficiency (3 findings)
- O(n) linear scans in
NameCacheValue(per lookup on hot paths) names()deep-copies all pairs on every call (hot path)invalidateAll()per-key lock acquisition + double generation bump
Code Quality (2 findings)
unregisterTablemay use wrong cache key in mode-2- ~150 lines duplicated across ExternalCatalog and ExternalDatabase
suxiaogang223
left a comment
There was a problem hiding this comment.
Code Review — Nit-level suggestions
Overall the architecture is sound and the concurrency model is well-designed. Approve. Below are four minor/nit suggestions for consideration:
1. [Nit] NameCacheValue.names() deep-copies all Pair objects on every call
NameCacheValue.java — names() method
public List<Pair<String, String>> names() {
return ImmutableList.copyOf(copyPairs(names));
}This is called from listLocalDatabaseNamesFromCache() → getDbNames() on every SHOW DATABASES / SHOW TABLES. The internal field is already an ImmutableList of already-copied Pairs. The second copyPairs() creates N new Pair objects on every read. For catalogs with thousands of databases/tables, this allocates significant garbage on a hot path. Consider either:
- Returning the internal
ImmutableListdirectly with a documented contract that callers must not mutate Pair contents, or - Caching the defensive copy result.
2. [Nit] NameCacheValue.remoteNameOfLocalName() and containsLocalName() are O(n) linear scans
NameCacheValue.java lines 1841–1857
public String remoteNameOfLocalName(String localName) {
for (Pair<String, String> pair : names) {
if (pair.value().equals(localName)) {
return pair.key();
}
}
return null;
}Every local-to-remote name resolution scans all pairs linearly. For catalogs with 10,000+ tables, this is expensive on hot paths like buildDbForInit() and buildTableForInit(). The constructor already builds a lowerCaseToRemoteName HashMap — building a parallel localName→remoteName HashMap (and a localName HashSet for containsLocalName) would make both methods O(1) at a small memory cost.
3. [Nit] Post-put generation re-check in getWithManualLoad is a test-only code path
MetaCacheEntry.java — getWithManualLoad() method
synchronized (publishLock(key)) {
// ...
putLoadedValueWithoutGenerationBump(key, loaded);
if (generation != generationOf(key)) { // ← unreachable in production
removeLoadedValueWithoutGenerationBump(key, loaded);
}
}The generation cannot change while holding publishLock because the only code that bumps a stripe generation (bumpGeneration()) is called from methods that also hold publishLock for that key. This branch exists solely to support the beforeManualCachePutForTest test hook, which can trigger invalidateAll() from within the hook callback. Adding a comment here would prevent future readers from misinterpreting this as unnecessary defensive programming — something like:
// This re-check is reachable only in tests that inject an invalidateAll()
// via beforeManualCachePutForTest while publishLock is held.
if (generation != generationOf(key)) {
removeLoadedValueWithoutGenerationBump(key, loaded);
}4. [Nit] ~150 lines of duplicated logic across ExternalCatalog and ExternalDatabase
Seven method pairs are structurally identical between the two classes:
| ExternalCatalog | ExternalDatabase |
|---|---|
resolveDatabaseNameFromSnapshot() |
resolveTableNameFromSnapshot() |
getDatabaseNamesValue() |
getTableNamesValue() |
listLocalDatabaseNamesFromCache() |
listLocalTableNamesFromCache() |
getRemoteDatabaseName() |
getRemoteTableName() |
updateDatabaseCache() |
updateTableCache() |
invalidateDatabaseCache() |
invalidateTableCache() |
invalidateAllDatabaseCache() |
invalidateAllTableCache() |
Any future change to the miss-refresh policy or incremental update semantics must be applied in two places. Consider extracting a shared helper (e.g., ExternalMetaCacheCoordinator<K, V>) parameterized over the names entry, objects entry, and ID map fields.
Verdict: Approve. These are all minor code quality improvements — none affect correctness. Happy to see this land as-is, with these items as optional follow-up.
|
run buildall |
|
/review |
There was a problem hiding this comment.
Reviewed PR 65126 at head 157f67675ef23356b4881f1965a97f121b828d77.
I did not find a new non-duplicate blocking issue, so I am submitting this as a comment-only review with no inline comments.
Critical checkpoint conclusions:
- Goal and scope: the patch consistently replaces the old
MetaCachewrapper withMetaCacheEntryand immutable name snapshots across catalog, database, HMS, tests, and regression coverage. - Correctness: database/table name resolution, replay cache-only paths, and mode-2 case-insensitive fallbacks were checked against the changed code and existing review threads.
- Concurrency: manual miss loading now holds the per-key load lock through slow load and publish, and invalidation/publish generation checks were reviewed in
MetaCacheEntry. - Lifecycle: reset and removal-listener behavior remain explicit through cache invalidation/reset paths.
- Configuration: the new name-miss-refresh and stripe-count configs were reviewed. The removed manual miss-load switch is ignored with a startup warning if stale in config files, while runtime mutation correctly rejects removed keys.
- Compatibility: no persisted schema format change was found; replay paths avoid synchronous remote load-through.
- Tests: added FE unit tests and regression cases cover the main cache/refactor paths, including replay and refresh behavior. I did not run FE tests locally because this checkout lacks
thirdparty/installedandthirdparty/installed/bin/protoc. - Local validation:
git diff --check 74e005f69050d993d1b480d13e55a4072554c4ba HEAD -- . ':(exclude).code-review.QoBYRt'passed. - Subagent convergence: both required subagents (
optimizer-rewriteandtests-session-config) reportedNO_NEW_VALUABLE_FINDINGSafter the final empty comment set was recorded in the shared ledger.
FE UT Coverage ReportIncrement line coverage |
TPC-H: Total hot run time: 29387 ms |
|
run buildall |
TPC-H: Total hot run time: 29518 ms |
TPC-DS: Total hot run time: 178256 ms |
ClickBench: Total hot run time: 25.05 s |
|
/review |
|
Codex automated review failed and did not complete. Error: You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Jul 25th, 2026 6:03 AM. Please inspect the workflow logs and rerun the review after the underlying issue is resolved. |
|
run buildall |
TPC-H: Total hot run time: 29146 ms |
TPC-DS: Total hot run time: 178004 ms |
ClickBench: Total hot run time: 24.9 s |
FE UT Coverage ReportIncrement line coverage |
|
/review |
There was a problem hiding this comment.
Automated review completed for PR #65126 at head 7533c1d16eb7158b9d0a638198ddb20d7fddc725.
I found two patch-scoped P2 performance regressions that should be addressed:
- Delegated Iceberg REST
isTableExist()now enumerates the whole database before its point existence request in the ordinary no-mapping mode. - Selective cache invalidation advances every generation stripe, preventing unrelated in-flight metadata loads from being cached.
Critical checkpoint conclusions:
- Goal and test intent: The refactor cleanly separates external names, objects, retained IDs, and engine-cache entries, and adds substantial concurrency, replay, event, and session-bypass coverage. The two reported cases are not covered by the added tests.
- Focus and scope: No additional user focus was provided. I reviewed all 30 changed files plus the relevant catalog, replay, Iceberg REST, engine-cache, Caffeine, configuration, and constraint call chains.
- Concurrency and locks: Manual miss publication, refresh publication, public mutations, bulk invalidation, synchronous removal callbacks, and event-lock boundaries were traced. Besides the selective-invalidation issue reported inline, no new non-duplicate deadlock or stale-publication regression survived validation; the capacity-eviction/refresh race is already covered by existing thread
r3599912105. - Lifecycle and cleanup: Names/object/ID/engine hot-cold combinations, TTL/capacity eviction, reset, CREATE/DROP/rename, and cache-only replay were checked. Current patch-scoped cleanup paths are consistent after the fixes already discussed on this PR.
- Configuration and compatibility: Mutable name-miss behavior, removed legacy cache configuration, immutable list exposure, lower-case modes 0/1/2, name mapping, and current/legacy refresh edit logs were reviewed. No additional patch-scoped compatibility issue was established.
- Parallel paths and conditionals: HMS events, manual refresh, leader logging, follower replay, delegated Iceberg REST access, ordinary shared-cache access, canonical local names, deterministic IDs, and engine-specific invalidation were compared.
- Tests and validation: Review was static as required by the review instructions; no local build or test was run. Current CI reports compile, FE unit tests, external regression, P0 regression, and checkstyle passing. The FE coverage status is failing despite a posted 86.59% incremental-line report, and the automated code-review check is still pending.
- Observability, persistence, and failover: Cache statistics, invalidation logging, removal callbacks, edit-log replay, and retained-ID recovery were inspected; no additional patch-scoped issue was found.
- Data writes and variables: This change does not introduce a durable data-write path. Session context, remote/local database and table names, IDs, generation values, and update timestamps were traced through their consumers.
- Performance and other issues: The two new regressions are inline. The separate O(n) copy-on-write HMS name-update cost is already covered by
r3602162049and issue #65779. Duplicate, fixed, pre-existing, and unproven candidates were suppressed.
Three independent convergence lanes re-reviewed the current two-comment set and returned NO_NEW_VALUABLE_FINDINGS.
### What problem does this PR solve? Issue Number: None Related PR: apache#65126 Problem Summary: Delegated Iceberg REST session table-existence checks enumerated all tables even when the requested name could be used directly. Keep mode 0 as a direct point lookup, and enumerate without shared caches only when lower-case or explicit name mapping requires local-to-remote resolution. ### Release note Reduce unnecessary Iceberg REST metadata enumeration for delegated session table-existence checks. ### Check List (For Author) - Test: Unit Test - ExternalDatabaseSessionContextTest: 8 tests passed - ExternalDatabaseTest: 22 tests passed - FE checkstyle passed - Behavior changed: Yes. Mode 0 delegated session existence checks now use a point lookup. - Does this need documentation: No
|
run buildall |
TPC-H: Total hot run time: 29658 ms |
TPC-DS: Total hot run time: 178689 ms |
ClickBench: Total hot run time: 25 s |
FE UT Coverage ReportIncrement line coverage |
|
run buildall |
TPC-H: Total hot run time: 29030 ms |
TPC-DS: Total hot run time: 177027 ms |
ClickBench: Total hot run time: 25.13 s |
FE UT Coverage ReportIncrement line coverage |
What problem does this PR solve?
Issue Number: N/A
Related PR: #64705
Problem Summary:
This PR refactors the FE external metadata cache from the old
MetaCache<T>wrapper into independentMetaCacheEntryinstances and introducesNameCacheValueas the immutable names snapshot used by catalog/database name resolution.The refactor keeps slow remote I/O out of Caffeine's synchronous load path, makes publication/invalidation generation-aware, and unifies the lower-case mode 0/1/2 behavior around one names snapshot.
After the initial refactor, PMC review feedback identified several follow-up items. This branch now incorporates those review-driven updates:
NameCacheValuehot-path lookups by precomputing local-name indexes and exposing a cachedlocalNames()view, while keepingnames()defensive becausePairis mutable.Some review findings were intentionally not turned into extra logic changes in this PR:
Implementation details
1. Overall architecture
The legacy cache grouped the names cache, object cache, and ID-to-name index in one
MetaCache<T>wrapper:The refactored structure makes each logical dataset an independent
MetaCacheEntry:The main architectural changes are:
MetaCache<T>wrapper that owned twoLoadingCacheinstances.MetaCacheEntry<K, V>for each logical cache dataset.NameCacheValuesnapshot.2. Configuration
Config.external_meta_cache_object_entry_lock_stripesis a non-mutable configuration with a default value of256. It controls the number of load-lock, publication-lock, and generation stripes used by multi-keyMetaCacheEntryinstances. Keys mapped to the same stripe share the same load and publication locks, so the value balances memory usage against unrelated-key contention.Config.enable_external_meta_cache_name_miss_refreshis mutable and defaults totrue. It controls what happens when a names snapshot is already cached but a requested database or table name is absent:The option is enabled by default to preserve the existing external catalog visibility behavior. A database or table created outside Doris can therefore become visible on the next direct lookup without requiring an explicit refresh. Operators can disable the option to avoid repeated expensive
listDatabaseNames()orlistTableNames()calls when non-existent objects are queried frequently; with the option disabled, externally created objects become visible through an explicit refresh or metadata synchronization.The previous
enable_external_meta_cache_manual_miss_loadswitch is removed.MetaCacheEntry.get()now always uses manual miss loading and can no longer fall back toLoadingCache.get()for synchronous loader execution.3.
MetaCacheEntry3.1 Concurrency state
MetaCacheEntryreplaces the fixed 128 load-lock stripes and single global invalidation generation with configurable per-stripe state:loadLocksdeduplicate slow miss loads. Slow external I/O runs while holding only the corresponding load stripe, not a Caffeine bin lock or publication lock.publishLocksprotect short cache publication, explicit mutation, and key invalidation windows.generationsstores one generation per stripe so mutations to one stripe do not invalidate in-flight work on every other stripe.3.2 Construction
The existing common constructors remain available, and overloads accepting an explicit
stripeCountare added. All public constructors converge on one private constructor that validates the supported combinations and builds the underlying Caffeine cache.withSyncRemovalListener(...)creates an entry with a synchronous removal listener. This path forcesautoRefresh=false, because the existing synchronous-removal-listener cache construction must not be combined withrefreshAfterWrite. Contextual-only entries must continue to use a null default loader and cannot enable automatic refresh.All entries now use the custom
CacheLoaderreturned bynewCacheLoader()so thatrefreshAfterWritereloads participate in generation checks.3.3 Read APIs
get(K)always callsgetWithManualLoad()with the default loader. It no longer reads a runtime feature switch and never invokesloadingData.get(key)for a normal miss.get(K, Function<K, V>)uses the same manual path but executes the contextual loader supplied by the caller.getIfPresent(K)never performs synchronous load-through. It returns null when the entry is disabled. A cache hit may still cause Caffeine to schedule asynchronousrefreshAfterWrite, but the caller never waits for remote I/O.3.4 Explicit mutations
put(K, V)rejects null keys and values and is a no-op when caching is disabled. Otherwise, it acquires the key's publication lock, increments the stripe generation, and writes the value. Incrementing the generation prevents an earlier slow load from later overwriting the explicit value.compute(K, BiFunction<K, V, V>)is the generation-aware replacement for upper-layer direct access todata.asMap().compute(). It performs the generation bump and map mutation under the publication lock. As withConcurrentMap.compute, a null remapping result removes the key. When caching is disabled, the remapping function is not executed.3.5 Invalidation
invalidateKey(K)acquires the publication lock, bumps the key stripe generation, removes the key, and increments the invalidation count only when a cached value was actually removed.invalidateIf(Predicate<K>)first callsbumpAllGenerations()so that in-flight loads whose keys have not yet appeared in the cache map cannot publish after the invalidation. It then iterates visible keys and delegates matching keys toinvalidateKey(). The initial all-stripe bump is intentionally conservative: an absent in-flight key cannot be evaluated against the predicate without additional tracking.invalidateAll()also bumps every stripe before iterating and invalidating each visible key. It no longer calls Caffeine'sinvalidateAll()directly, ensuring that published keys pass through the same publication coordination as single-key invalidation.3.6 Manual miss loading
getWithManualLoad()implements the following sequence:getIfPresent()on the fast path.putLoadedValueWithoutGenerationBump()and retain a post-put generation check.putLoadedValueWithoutGenerationBump()is deliberately separate from the publicput(): an internal loader publication must not invalidate the generation it captured for itself.removeLoadedValueWithoutGenerationBump()removes the value only when the currently cached object is the same object published by that load. This avoids deleting a newer explicit replacement.3.7 Generation-aware asynchronous refresh
newCacheLoader()keepsload()for Caffeine refresh support but normal misses no longer use it. ItsasyncReload()implementation:3.8 Stripe helpers
stripe(K)maps a key hash to a bounded stripe.loadLock(K)andpublishLock(K)return the corresponding lock objects.generationOf(K)reads the stripe generation,bumpGeneration(K)invalidates work on one stripe, andbumpAllGenerations()invalidates publication eligibility across all stripes, including loads not yet represented in the cache map.defaultObjectStripeCount()reads the configured multi-key stripe count.singleKeyStripeCount()returns one for names entries, whose only key is the empty string.4.
NameCacheValueNameCacheValueis a new immutable snapshot containing both:(remoteName, localName)pairs.lower-case name -> original remote nameindex for case-insensitive lookup.The constructor deep-copies every
Pair, builds the index withLocale.ROOT, and uses last-write-wins behavior for the index. Catalog-aware loaders remain responsible for detecting unsupported case conflicts.of(...)creates a non-null snapshot, andempty()creates an empty snapshot for explicit forced-update paths.names()returns freshPairobjects so callers cannot mutate a published snapshot through the mutablePairtype.remoteNameOfLocalName()replaces the legacyMetaCache.getRemoteName()lookup and now uses a precomputed local-name index.containsLocalName()supports existence checks in object initialization and now uses a precomputed local-name set.localNames()exposes a cached immutable local-name view for hot read paths likeSHOW DATABASESandSHOW TABLES.remoteNameForCaseInsensitiveLookup()replaces the separatelowerCaseToDatabaseNameandlowerCaseToTableNamemaps.withName(remoteName, localName)performs copy-on-write insertion. It rejects an exact remote name already mapped to a different local name, removes any old entry for the same local name, adds the new pair, and returns a new snapshot. Callers update this names snapshot before object and ID state so a conflict cannot leave a partially updated object cache.withoutLocalName(localName)performs copy-on-write removal and returns a new snapshot.copyPairs()provides the deep-copy boundary used during construction and readout.5.
ExternalCatalog5.1 Cache structure and initialization
The legacy
MetaCache<ExternalDatabase>andlowerCaseToDatabaseNameside map are replaced bydatabaseNames,databases, anddbIdToName.buildMetaCache()creates:databaseNames, keyed only by"", with aNameCacheValue, capacity one, one stripe, andrefreshAfterWriteenabled.databases, keyed by local database name, with configurable object stripes,refreshAfterWritedisabled, and a synchronous removal listener that callsresetMetaToUninitialized()on the removed database object.getFilteredDatabaseNames()still performs remote enumeration, include/exclude filtering, remote-to-local mapping, system database handling, and case-conflict validation. It no longer mutates a separate lower-case side map and now acts as the pure loader for a complete names snapshot.buildDbForInit()routes existence checks throughresolveDatabaseNameFromSnapshot()and routes local-to-remote resolution throughgetRemoteDatabaseName(). The system database construction branches and the existing unit-testrunningUnitTestbypass condition are preserved.5.2 Normal and replay lookup
getDbNames()reads local database names fromdatabaseNamesinstead ofMetaCache.listNames().getDbNullable(String)preservesinformation_schemaandmysqlname normalization. Other names are resolved to a local name beforedatabases.get()performs manual miss loading. A successful lookup updatesdbIdToNameusingdb.getId()as the single ID source.getDbNullable(long)resolves the local name throughdbIdToNameand then callsdatabases.get(). It returns null immediately when no ID mapping exists.getDbForReplay(long)checks initialization, resolves the key throughdbIdToName, and usesdatabases.getIfPresent(). This fixes the legacy path wheregetMetaObjById()could perform load-through during replay.getDbForReplay(String)first checks the exact object key. On a miss, it resolves the local name only from an existing names snapshot and performs anothergetIfPresent(). If mode 2 replay sees a cold names snapshot but the object cache is still hot, it now performs a cache-only case-insensitive fallback over hot object-cache keys instead of reloading names. Replay misses never perform synchronous remote loading. A hot names entry may still schedule backgroundrefreshAfterWrite, but the replay thread does not wait for it.5.3 Name resolution
getLocalDatabaseName()preserves modes 0 and 1. Mode 2 now resolves the original-case name throughNameCacheValue.remoteNameForCaseInsensitiveLookup().getDatabaseNamesValue(boolean allowLoad)centralizes the choice betweendatabaseNames.get("")anddatabaseNames.getIfPresent("").resolveDatabaseNameFromSnapshot()centralizes negative-lookup behavior:enable_external_meta_cache_name_miss_refreshis disabled, it returns null without enumerating remote names again.listLocalDatabaseNamesFromCache()loads the snapshot when necessary and extracts local names from the cachedlocalNames()view.getRemoteDatabaseName()uses the shared resolver andremoteNameOfLocalName()to replaceMetaCache.getRemoteName().5.4 Incremental updates and invalidation
updateDatabaseCache()updates state in names -> object -> ID order. Runtime incremental events update names and object entries only when those entries are already hot: a single create event must not materialize an incomplete names snapshot or preheat an object the FE has never consumed. The lightweightdbIdToNamenavigation index is always updated fromdb.getId(), so normal by-ID lookup can load a cold object on demand. The event path and cold object loader use the same deterministic ID rule, so the reconstructed object retains the event ID. Replay by-ID lookup remains cache-only.invalidateDatabaseCache()removes the local name through copy-on-write only when the names snapshot is already hot, invalidates the object key, and removes every ID mapping whose value equals that local name. Drop events therefore do not load a cold names entry.invalidateDatabaseNamesOnly()invalidates only the names entry.invalidateAllDatabaseCache()invalidates names and objects and clears the ID index.refreshMetaCacheOnly()now delegates to the full helper.unregisterDatabase()delegates local state cleanup toinvalidateDatabaseCache()while preserving the following engine-levelExternalMetaCacheMgr.invalidateDb()call.resetMetaCacheNames()preserves the existing CREATE DATABASE behavior by invalidating only the names entry.resetToUninitialized()no longer clears a separate lower-case map.gsonPostProcess()reconstructs an emptydbIdToName, making the ID map explicitly runtime-only rather than image-persisted state.6.
ExternalDatabaseExternalDatabaseapplies the same structure at table scope.MetaCache<T>andlowerCaseToTableNameare replaced bytableNames,tables, andtableIdToName.buildMetaCache()creates a single-key, auto-refreshingtableNamesentry and a multi-keytablesentry withautoRefresh=false. The table object entry does not need the database-level removal callback used byExternalCatalog.databases.listTableNames()preserves system database branches, remote enumeration, remote-to-local mapping, and conflict checks, but no longer mutates a lower-case side map.resetMetaToUninitialized()delegates toinvalidateAllTableCache()so names, objects, and IDs are cleared together before engine-level database cache invalidation.buildTableForInit()usesresolveTableNameFromSnapshot()for existence checks andgetRemoteTableName()for local-to-remote resolution. The original outer condition deciding whether remote-name mapping is necessary and the unit-test bypass condition are preserved.getTableNamesWithLock()extracts local names fromNameCacheValue.getTableNullable(String)resolves the local name, performstables.get()manual loading, and updatestableIdToNamefromtable.getId().getTableNullable(long)resolves the local name from the ID index and then callstables.get().Both
getTableForReplay(long)andgetTableForReplay(String)require initialized state and usegetIfPresent()for the final object lookup. The string overload checks the exact key before consulting the cached names snapshot. If mode 2 replay sees a cold names snapshot but the object cache is still hot, it now falls back to a cache-only case-insensitive scan over hot object-cache keys. Replay misses do not synchronously enumerate remote tables or load table objects.isTableExist()no longer callslistTableNames()directly to populate a side map. For case-insensitive names, it resolves the remote name through the shared names resolver and then calls the catalog'stableExist()implementation.getLocalTableName()preserves the existing lower-case storage modes and usesNameCacheValuefor case-insensitive original-name resolution.getTableNamesValue(),resolveTableNameFromSnapshot(),listLocalTableNamesFromCache(), andgetRemoteTableName()are the table-level counterparts of the database-name helpers described above.updateTableCache()uses the same split semantics as the database side: names and object entries are updated only when already hot, whiletableIdToNameis always updated from the actual table ID. NormalgetTableNullable(long)can therefore resolve the name and load a cold table object on demand. The event path and loader share the same deterministic table-ID rule; Replay still usesgetIfPresent().invalidateTableCache()removes the name from a hot snapshot, invalidates the object key, and removes matching ID mappings.unregisterTable()derives the local cache key with the same remote-to-local rules used by register/list and performs this local cleanup even when the object entry is cold or already evicted. Engine-specific cache invalidation is invoked only when the cachedExternalTableobject is available.invalidateAllTableCache()clears all three structures.registerTable()andunregisterTable()now delegate to these centralized update/invalidation helpers while preserving their surrounding timestamp behavior.resetMetaCacheNames()still invalidates only table names.gsonPostProcess()reconstructs an empty runtime-onlytableIdToNamemap.7.
ExternalMetaCacheMgrThe
LegacyMetaCacheFactoryfield, its construction, and thelegacyMetaCacheFactory()accessor are removed because catalog and database objects now buildMetaCacheEntryinstances directly.commonRefreshExecutor()exposes the existing common refresh executor to those construction paths. It replaces the old factory accessor without creating a new thread pool or changing executor ownership.8.
HMSExternalCatalogregisterDatabase(long, String)now delegates cache maintenance toExternalCatalog.updateDatabaseCache()instead of callingmetaCache.updateCache()directly. This centralizes names/object/ID ordering, hot-only names/object updates, always-on ID navigation maintenance, and the single ID source. The duplicateUtil.genIdByName()call is removed because the helper usesdb.getId().9.
AbstractExternalMetaCachenewMetaCacheEntry()now passesMetaCacheEntry.defaultObjectStripeCount()explicitly when constructing existing schema, partition, and other engine-specific entries. The new stripe configuration therefore applies consistently both to the database/table object caches migrated in this PR and to multi-key entries managed by this class.Release note
Improve external metadata cache replay lookup and hot-path name resolution in FE.
Check List (For Author)
Manual test details:
tools/manual_external_meta_cache_regression/verify_name_miss_refresh_mutable.shtools/manual_external_meta_cache_regression/verify_table_names_refresh_non_blocking.shtools/manual_external_meta_cache_regression/verify_schema_refresh_non_blocking.shUnit test details:
./run-fe-ut.sh --run org.apache.doris.datasource.metacache.MetaCacheEntryTest./run-fe-ut.sh --run org.apache.doris.datasource.metacache.NameCacheValueTest,org.apache.doris.datasource.ExternalDatabaseTest,org.apache.doris.datasource.ExternalCatalogTestBehavior changed:
Does this need documentation:
Check List (For Reviewer who merge this PR)