diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScopes.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScopes.java
new file mode 100644
index 00000000000000..3f62dcdf993f82
--- /dev/null
+++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScopes.java
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.api;
+
+import java.util.function.Supplier;
+
+/**
+ * Statement-scoped resolution helper over the neutral {@link ConnectorStatementScope} (reached via
+ * {@link ConnectorSession#getStatementScope()}). A connector routes its "resolve {@code db.table} once per
+ * statement" memo through {@link #resolveInStatement}, so every read / scan / write resolver of one statement
+ * shares the single loaded value and the loader runs at most once — while a reused prepared-statement scope,
+ * reset per execution, still isolates each execution (its {@code queryId} is part of the key).
+ *
+ *
This standardizes the security-critical key convention once instead of letting each connector re-derive it:
+ * dropping {@code queryId} would leak a table across executions of a reused prepared statement; dropping
+ * {@code catalogId} would collide across a cross-catalog {@code MERGE}.
+ *
+ *
{@code keyNamespace} is connector-owned; this module stays source-neutral. {@code keyNamespace}
+ * namespaces the value type stored under the shared {@code (catalogId, db, table, queryId)} coordinate,
+ * so a heterogeneous gateway statement touching two connectors cannot collide on {@code (db, table)} and hand one
+ * connector another's value (a {@link ClassCastException}). Each connector declares its own namespace constant in
+ * its own module (e.g. {@code IcebergStatementScope}, {@code HudiStatementScope}, {@code EsStatementScope},
+ * {@code JdbcConnectorMetadata}) — this API deliberately holds no source names. The convention every
+ * {@code keyNamespace} passed to {@link #resolveInStatement} MUST follow: it is a compile-time constant prefixed
+ * with the connector's connector-type name (its {@code ConnectorProvider.getType()}, e.g. {@code "iceberg"},
+ * {@code "hudi"}). Because {@code getType()} is a connector's unique identity, source-prefixing makes these
+ * namespaces distinct across connectors by construction; each connector's {@code *StatementScope} guards
+ * its own prefix with a unit test.
+ *
+ *
This convention governs only the {@code keyNamespace} passed to {@link #resolveInStatement}. Other
+ * per-statement memos that use {@link ConnectorStatementScope}'s {@code computeIfAbsent} /
+ * {@code getOrCreateMetadata} directly own their own key discipline and are out of scope here — e.g. an
+ * engine-reserved family that stores a single value type per key, discriminated by catalog id, stays collision-safe
+ * without a source prefix. A connector that instead memoizes on its own per-statement metadata instance needs no
+ * namespace at all.
+ */
+public final class ConnectorStatementScopes {
+
+ private ConnectorStatementScopes() {
+ }
+
+ /**
+ * Resolves {@code db.table} once per statement and shares the single value across every resolver of the
+ * statement. The key is {@code keyNamespace + ":" + catalogId + ":" + db + ":" + table + ":" + queryId}: the
+ * catalog id isolates a cross-catalog {@code MERGE}, the {@code queryId} isolates each execution of a reused
+ * prepared statement, and {@code keyNamespace} isolates value types across a heterogeneous gateway.
+ * {@code keyNamespace} MUST be a connector-owned, source-prefixed constant (see the class javadoc).
+ * {@code loader} runs at most once per statement; under a {@code null} session or
+ * {@link ConnectorStatementScope#NONE} (offline / no live statement) it runs on every call — byte-identical
+ * to loading every time.
+ */
+ public static T resolveInStatement(ConnectorSession session, String keyNamespace,
+ String db, String table, Supplier loader) {
+ if (session == null) {
+ return loader.get();
+ }
+ String key = keyNamespace + ":" + session.getCatalogId() + ":" + db + ":" + table
+ + ":" + session.getQueryId();
+ return session.getStatementScope().computeIfAbsent(key, loader);
+ }
+}
diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorStatementScopesTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorStatementScopesTest.java
new file mode 100644
index 00000000000000..0011b3582419bb
--- /dev/null
+++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorStatementScopesTest.java
@@ -0,0 +1,225 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.api;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+/**
+ * Tests for {@link ConnectorStatementScopes#resolveInStatement}: the shared per-statement resolver whose key is
+ * {@code keyNamespace + ":" + catalogId + ":" + db + ":" + table + ":" + queryId}. It proves the memo collapses
+ * repeat resolves within a statement, that each key axis (namespace / catalog id / db / table / queryId) isolates,
+ * that a null session or {@link ConnectorStatementScope#NONE} degrades to load-every-time, and that the emitted
+ * key is exactly the documented string (the byte contract every connector consumer depends on).
+ */
+public class ConnectorStatementScopesTest {
+
+ @Test
+ public void sameCoordinateResolvesOncePerStatement() {
+ // Same (namespace, catalog, db, table, queryId) within one scope -> loader runs once, one shared instance.
+ // MUTATION: not memoizing -> two loads / two instances -> red.
+ RecordingMemoScope scope = new RecordingMemoScope();
+ TestSession session = new TestSession(7L, "q1", scope);
+ AtomicInteger loads = new AtomicInteger();
+ Object a = ConnectorStatementScopes.resolveInStatement(session, "x.table", "db1", "t", () -> {
+ loads.incrementAndGet();
+ return new Object();
+ });
+ Object b = ConnectorStatementScopes.resolveInStatement(session, "x.table", "db1", "t", () -> {
+ loads.incrementAndGet();
+ return new Object();
+ });
+ Assertions.assertSame(a, b, "same coordinate -> one shared instance");
+ Assertions.assertEquals(1, loads.get(), "loaded once per statement");
+ }
+
+ @Test
+ public void differentQueryIdIsolatesTheLoad() {
+ // A reused prepared statement runs each execution under its own queryId; the memo never crosses executions.
+ RecordingMemoScope scope = new RecordingMemoScope();
+ Object a = ConnectorStatementScopes.resolveInStatement(
+ new TestSession(7L, "q1", scope), "x.table", "db1", "t", Object::new);
+ Object b = ConnectorStatementScopes.resolveInStatement(
+ new TestSession(7L, "q2", scope), "x.table", "db1", "t", Object::new);
+ Assertions.assertNotSame(a, b, "different queryId -> isolated load");
+ }
+
+ @Test
+ public void differentCatalogIdIsolatesTheLoad() {
+ // A cross-catalog MERGE resolves the two catalogs' tables independently (the key carries the catalog id).
+ RecordingMemoScope scope = new RecordingMemoScope();
+ Object a = ConnectorStatementScopes.resolveInStatement(
+ new TestSession(1L, "q1", scope), "x.table", "db1", "t", Object::new);
+ Object b = ConnectorStatementScopes.resolveInStatement(
+ new TestSession(2L, "q1", scope), "x.table", "db1", "t", Object::new);
+ Assertions.assertNotSame(a, b, "different catalog id -> isolated load");
+ }
+
+ @Test
+ public void differentDbOrTableIsolatesTheLoad() {
+ RecordingMemoScope scope = new RecordingMemoScope();
+ TestSession session = new TestSession(7L, "q1", scope);
+ Object t1 = ConnectorStatementScopes.resolveInStatement(session, "x.table", "db1", "t", Object::new);
+ Object t2 = ConnectorStatementScopes.resolveInStatement(session, "x.table", "db1", "u", Object::new);
+ Object t3 = ConnectorStatementScopes.resolveInStatement(session, "x.table", "db2", "t", Object::new);
+ Assertions.assertNotSame(t1, t2, "different table -> isolated load");
+ Assertions.assertNotSame(t1, t3, "different db -> isolated load");
+ }
+
+ @Test
+ public void differentNamespaceIsolatesValueTypes() {
+ // The namespace guards a heterogeneous-gateway statement: two connectors sharing (db, table, catalog,
+ // queryId) but storing different value types must not collide. Without namespacing, the second resolve
+ // would return the first's memoized value cast to the wrong type -> ClassCastException.
+ // MUTATION: dropping keyNamespace from the key -> ClassCastException here -> red.
+ RecordingMemoScope scope = new RecordingMemoScope();
+ TestSession session = new TestSession(7L, "q1", scope);
+ String asString = ConnectorStatementScopes.resolveInStatement(
+ session, "a.value", "db1", "t", () -> "stringValue");
+ Integer asInt = ConnectorStatementScopes.resolveInStatement(
+ session, "b.value", "db1", "t", () -> 42);
+ Assertions.assertEquals("stringValue", asString, "namespace a keeps its String value");
+ Assertions.assertEquals(Integer.valueOf(42), asInt, "namespace b keeps its Integer value (no collision)");
+ }
+
+ @Test
+ public void nullSessionLoadsEveryTime() {
+ // No session (offline / direct-construction tests): each call loads, byte-identical to loading every time.
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorStatementScopes.resolveInStatement(null, "x.table", "db1", "t", () -> {
+ loads.incrementAndGet();
+ return new Object();
+ });
+ ConnectorStatementScopes.resolveInStatement(null, "x.table", "db1", "t", () -> {
+ loads.incrementAndGet();
+ return new Object();
+ });
+ Assertions.assertEquals(2, loads.get(), "null session -> load every time");
+ }
+
+ @Test
+ public void noneScopeLoadsEveryTime() {
+ // A live session whose scope is NONE (no per-statement context) also loads every time.
+ TestSession session = new TestSession(7L, "q1", ConnectorStatementScope.NONE);
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorStatementScopes.resolveInStatement(session, "x.table", "db1", "t", () -> {
+ loads.incrementAndGet();
+ return new Object();
+ });
+ ConnectorStatementScopes.resolveInStatement(session, "x.table", "db1", "t", () -> {
+ loads.incrementAndGet();
+ return new Object();
+ });
+ Assertions.assertEquals(2, loads.get(), "NONE scope -> load every time");
+ }
+
+ @Test
+ public void keyIsNamespaceCatalogDbTableQueryId() {
+ // The exact byte contract: keyNamespace + ":" + catalogId + ":" + db + ":" + table + ":" + queryId.
+ // Every connector consumer's cross-statement isolation depends on this string; assert it verbatim.
+ RecordingMemoScope scope = new RecordingMemoScope();
+ ConnectorStatementScopes.resolveInStatement(
+ new TestSession(7L, "q1", scope), "test.ns", "db1", "t", Object::new);
+ Assertions.assertEquals("test.ns:7:db1:t:q1", scope.lastKey, "emitted key must match the documented format");
+ }
+
+ /** A statement scope that memoizes like the engine's real one and records the last key, for the assertions. */
+ private static final class RecordingMemoScope implements ConnectorStatementScope {
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+ private String lastKey;
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T computeIfAbsent(String key, Supplier loader) {
+ lastKey = key;
+ return (T) cache.computeIfAbsent(key, k -> loader.get());
+ }
+ }
+
+ /** Minimal {@link ConnectorSession} carrying a catalog id, queryId and scope for the key + memo assertions. */
+ private static final class TestSession implements ConnectorSession {
+ private final long catalogId;
+ private final String queryId;
+ private final ConnectorStatementScope scope;
+
+ TestSession(long catalogId, String queryId, ConnectorStatementScope scope) {
+ this.catalogId = catalogId;
+ this.queryId = queryId;
+ this.scope = scope;
+ }
+
+ @Override
+ public long getCatalogId() {
+ return catalogId;
+ }
+
+ @Override
+ public String getQueryId() {
+ return queryId;
+ }
+
+ @Override
+ public String getSessionId() {
+ // Deliberately != queryId so keyIsNamespaceCatalogDbTableQueryId pins the per-execution queryId
+ // (cross-query isolation), not the stable per-connection sessionId; a queryId->sessionId swap in the
+ // helper's key would share a table across queries of one connection and MUST turn that assertion red.
+ return "session-" + queryId;
+ }
+
+ @Override
+ public ConnectorStatementScope getStatementScope() {
+ return scope;
+ }
+
+ @Override
+ public String getUser() {
+ return "u";
+ }
+
+ @Override
+ public String getTimeZone() {
+ return "UTC";
+ }
+
+ @Override
+ public String getLocale() {
+ return "en_US";
+ }
+
+ @Override
+ public String getCatalogName() {
+ return "c";
+ }
+
+ @Override
+ public T getProperty(String name, Class type) {
+ return null;
+ }
+
+ @Override
+ public Map getCatalogProperties() {
+ return Collections.emptyMap();
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java
index 47cd0596b46096..0524b31d402f48 100644
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/CacheSpec.java
@@ -67,6 +67,23 @@ public static CacheSpec of(boolean enable, long ttlSecond, long capacity) {
return new CacheSpec(enable, ttlSecond, capacity);
}
+ /**
+ * Build an ENABLED spec from a connector-resolved TTL under the "{@code <= 0} disables" contract.
+ *
+ * A connector that resolves its own single {@code ttl-second} knob (iceberg's shared
+ * {@code meta.cache.iceberg.table.ttl-second}, paimon's snapshot cache) treats any non-positive TTL as
+ * "disable caching, always read live". That is NOT the raw {@link CacheSpec} contract, which reads
+ * {@code ttlSecond == -1} as {@link #CACHE_NO_TTL} ("no expiration", still ENABLED) and only
+ * {@code ttlSecond == 0} as {@link #CACHE_TTL_DISABLE_CACHE} ("disabled"). This factory folds any
+ * non-positive TTL to the disable sentinel so a negative operator value disables the cache rather than
+ * silently becoming a never-expiring one. It is exactly the
+ * {@code ttlSecond > 0 ? of(true, ttlSecond, capacity) : of(true, CACHE_TTL_DISABLE_CACHE, capacity)}
+ * expression each per-catalog cache used to inline.
+ */
+ public static CacheSpec ofConnectorTtl(long ttlSecond, long capacity) {
+ return of(true, ttlSecond > 0 ? ttlSecond : CACHE_TTL_DISABLE_CACHE, capacity);
+ }
+
public static PropertySpec.Builder propertySpecBuilder() {
return new PropertySpec.Builder();
}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorPartitionViewCache.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorMetadataCache.java
similarity index 65%
rename from fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorPartitionViewCache.java
rename to fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorMetadataCache.java
index cd7f03be986ee7..fde68573e73c50 100644
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorPartitionViewCache.java
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorMetadataCache.java
@@ -24,18 +24,18 @@
import java.util.function.Supplier;
/**
- * GENERIC (engine-agnostic) cross-query cache of a connector's derived partition view — "cache A" of the
- * external-partition-derived-cache design (design doc {@code 2026-07-20-external-partition-derived-cache-design.md}
- * §5). It is the generic version of {@code org.apache.doris.connector.iceberg.IcebergPartitionCache}: same
- * construction pattern (a contextual-only, manual-miss-load {@link MetaCacheEntry}), same {@link CacheSpec} wiring,
- * same invalidation style — but keyed by the engine-agnostic {@link PartitionViewCacheKey} and holding an opaque
- * value {@code V} instead of an iceberg-specific raw-partition list, so it has no engine-specific imports and can be
- * shared by every connector (iceberg/paimon/hive/maxcompute, wired in later subsystems — this class has NO
- * consumers yet).
+ * GENERIC (engine-agnostic) cross-query cache of a connector's derived metadata, keyed by a table identity plus
+ * an optional MVCC coordinate ({@link ConnectorTableKey}) and holding an opaque value {@code V}. It is the generic
+ * form of the hand-rolled iceberg caches (e.g. {@code IcebergPartitionCache}): same construction pattern (a
+ * contextual-only, manual-miss-load {@link MetaCacheEntry}), same {@link CacheSpec} wiring, same invalidation style
+ * — but keyed by the engine-agnostic {@link ConnectorTableKey} and holding an opaque {@code V} instead of an
+ * engine-specific value, so it has no engine-specific imports and is shared by every connector. Consumers today:
+ * the hive/iceberg/paimon derived partition-view caches (entry {@code "partition_view"}); a connector may hold
+ * several instances under distinct entry names.
*
- *
Config: {@code meta.cache..partition_view.(enable|ttl-second|capacity)}, default ON / 86400s /
- * 1000 entries (matching {@code IcebergPartitionCache}'s {@code DEFAULT_TABLE_CACHE_CAPACITY}). {@code enable=false}
- * / {@code ttl-second=0} / {@code capacity=0} each disable the cache (see {@link CacheSpec#isCacheEnabled}): {@link
+ * Config: {@code meta.cache...(enable|ttl-second|capacity)}, default ON / 86400s / 1000
+ * entries (matching {@code IcebergPartitionCache}'s {@code DEFAULT_TABLE_CACHE_CAPACITY}). {@code enable=false} /
+ * {@code ttl-second=0} / {@code capacity=0} each disable the cache (see {@link CacheSpec#isCacheEnabled}): {@link
* #get} then calls the loader on every call, matching {@code IcebergPartitionCache}'s disabled-cache bypass.
*
* Concurrency: mirrors {@code IcebergPartitionCache} / {@code MaxComputePartitionCache} exactly — the
@@ -43,31 +43,32 @@
* load, so a slow remote enumeration runs OUTSIDE Caffeine's compute lock (deduplicated per key by a striped lock)
* on the calling thread, and its exception propagates to the caller unwrapped without poisoning the cache.
*/
-public final class ConnectorPartitionViewCache {
+public final class ConnectorMetadataCache {
- /** {@code meta.cache..partition_view.*} — the property-namespace entry name for this cache. */
- static final String ENTRY_PARTITION_VIEW = "partition_view";
/** Default TTL: 24h, matching the design doc's stated default and sibling caches' 24h TTL. */
static final long DEFAULT_TTL_SECOND = 86400L;
/** Default capacity, matching {@code IcebergPartitionCache}'s {@code DEFAULT_TABLE_CACHE_CAPACITY}. */
static final long DEFAULT_CAPACITY = 1000L;
- private final MetaCacheEntry entry;
+ private final MetaCacheEntry entry;
/**
- * @param engine engine token for the {@code meta.cache..partition_view.*} property namespace, e.g.
- * {@code "iceberg"}/{@code "paimon"}/{@code "hive"}/{@code "max_compute"}.
- * @param props the catalog properties; drives the {@link CacheSpec} (enable/ttl-second/capacity). May be
- * {@code null}, treated as empty (defaults apply).
+ * @param engine engine token for the {@code meta.cache...*} property namespace, e.g.
+ * {@code "iceberg"}/{@code "paimon"}/{@code "hive"}/{@code "max_compute"}.
+ * @param entryName the entry name within that namespace (e.g. {@code "partition_view"}); a connector may hold
+ * several {@code ConnectorMetadataCache}s under distinct entry names.
+ * @param props the catalog properties; drives the {@link CacheSpec} (enable/ttl-second/capacity). May be
+ * {@code null}, treated as empty (defaults apply).
*/
- public ConnectorPartitionViewCache(String engine, Map props) {
+ public ConnectorMetadataCache(String engine, String entryName, Map props) {
Objects.requireNonNull(engine, "engine can not be null");
+ Objects.requireNonNull(entryName, "entryName can not be null");
Map properties = props == null ? Collections.emptyMap() : props;
- CacheSpec spec = CacheSpec.fromProperties(properties, engine, ENTRY_PARTITION_VIEW,
+ CacheSpec spec = CacheSpec.fromProperties(properties, engine, entryName,
CacheSpec.of(true, DEFAULT_TTL_SECOND, DEFAULT_CAPACITY));
// contextual-only (loader == null, supplied per-call by get()) + manual-miss-load, no auto-refresh --
// identical shape to IcebergPartitionCache / MaxComputePartitionCache's entry construction.
- this.entry = new MetaCacheEntry<>(engine + "." + ENTRY_PARTITION_VIEW, null, spec,
+ this.entry = new MetaCacheEntry<>(engine + "." + entryName, null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
}
@@ -81,7 +82,7 @@ public boolean isEnabled() {
* Disabled cache -> {@code loader} runs on every call. The loader runs OUTSIDE Caffeine's compute lock
* (single-flight per key) and its exception propagates unwrapped; a failed load is never cached.
*/
- public V get(PartitionViewCacheKey key, Supplier loader) {
+ public V get(ConnectorTableKey key, Supplier loader) {
Objects.requireNonNull(loader, "loader can not be null");
return entry.get(key, ignored -> loader.get());
}
diff --git a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/PartitionViewCacheKey.java b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorTableKey.java
similarity index 84%
rename from fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/PartitionViewCacheKey.java
rename to fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorTableKey.java
index f00d39465475a6..41ac71c9930240 100644
--- a/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/PartitionViewCacheKey.java
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorTableKey.java
@@ -20,7 +20,7 @@
import java.util.Objects;
/**
- * Immutable cache key for {@link ConnectorPartitionViewCache}: {@code (db, table, snapshotId, schemaId)}.
+ * Immutable cache key for {@link ConnectorMetadataCache}: {@code (db, table, snapshotId, schemaId)}.
*
* Engine-agnostic (external-partition-derived-cache design doc §5, "cache A"): a table's derived partition
* view is a pure function of its identity plus the MVCC coordinate it was read at, so pinning that coordinate
@@ -28,18 +28,18 @@
* hit. Non-MVCC engines (hive) or engines without a separate schema version pass {@code snapshotId = -1} /
* {@code schemaId = -1}; the key still holds them, it just means "unversioned" for that axis.
*
- *
{@link #matches} / {@link #matchesDb} back {@link ConnectorPartitionViewCache#invalidateTable} /
- * {@link ConnectorPartitionViewCache#invalidateDb}, which must drop every snapshot/schema of a (db, table) or
+ *
{@link #matches} / {@link #matchesDb} back {@link ConnectorMetadataCache#invalidateTable} /
+ * {@link ConnectorMetadataCache#invalidateDb}, which must drop every snapshot/schema of a (db, table) or
* every table of a db — mirrors the {@code matches}/{@code matchesDb} helpers on the sibling connector caches
* ({@code MaxComputePartitionCache.PartitionKey}, {@code HiveFileListingCache.FileListingKey}).
*/
-public final class PartitionViewCacheKey {
+public final class ConnectorTableKey {
private final String db;
private final String table;
private final long snapshotId;
private final long schemaId;
- public PartitionViewCacheKey(String db, String table, long snapshotId, long schemaId) {
+ public ConnectorTableKey(String db, String table, long snapshotId, long schemaId) {
this.db = db;
this.table = table;
this.snapshotId = snapshotId;
@@ -77,10 +77,10 @@ public boolean equals(Object o) {
if (this == o) {
return true;
}
- if (!(o instanceof PartitionViewCacheKey)) {
+ if (!(o instanceof ConnectorTableKey)) {
return false;
}
- PartitionViewCacheKey that = (PartitionViewCacheKey) o;
+ ConnectorTableKey that = (ConnectorTableKey) o;
return snapshotId == that.snapshotId
&& schemaId == that.schemaId
&& Objects.equals(db, that.db)
@@ -94,7 +94,7 @@ public int hashCode() {
@Override
public String toString() {
- return "PartitionViewCacheKey{db=" + db + ", table=" + table
+ return "ConnectorTableKey{db=" + db + ", table=" + table
+ ", snapshotId=" + snapshotId + ", schemaId=" + schemaId + '}';
}
}
diff --git a/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/CacheSpecTest.java b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/CacheSpecTest.java
index 3e918bd80e811a..276735b40c2f9b 100644
--- a/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/CacheSpecTest.java
+++ b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/CacheSpecTest.java
@@ -188,6 +188,36 @@ public void ofSemantics() {
Assertions.assertFalse(disabled.isEnable());
}
+ @Test
+ public void ofConnectorTtlFoldsNonPositiveToDisabled() {
+ // A positive ttl passes through unchanged and stays enabled.
+ CacheSpec positive = CacheSpec.ofConnectorTtl(60, 100);
+ Assertions.assertTrue(positive.isEnable());
+ Assertions.assertEquals(60, positive.getTtlSecond());
+ Assertions.assertEquals(100, positive.getCapacity());
+ Assertions.assertTrue(CacheSpec.isCacheEnabled(
+ positive.isEnable(), positive.getTtlSecond(), positive.getCapacity()));
+
+ // ttl == 0 is already the disable sentinel and stays disabled.
+ CacheSpec zero = CacheSpec.ofConnectorTtl(0, 100);
+ Assertions.assertEquals(CacheSpec.CACHE_TTL_DISABLE_CACHE, zero.getTtlSecond());
+ Assertions.assertFalse(CacheSpec.isCacheEnabled(
+ zero.isEnable(), zero.getTtlSecond(), zero.getCapacity()));
+
+ // The load-bearing case: a NEGATIVE ttl must FOLD to the disable sentinel (0), NOT pass through as the
+ // -1 "no expiration (enabled)" sentinel. Without the fold, a negative operator value would silently
+ // produce a never-expiring cache -- the exact bug this factory replaces the per-cache ternary to avoid.
+ CacheSpec minusOne = CacheSpec.ofConnectorTtl(CacheSpec.CACHE_NO_TTL, 100);
+ Assertions.assertEquals(CacheSpec.CACHE_TTL_DISABLE_CACHE, minusOne.getTtlSecond());
+ Assertions.assertFalse(CacheSpec.isCacheEnabled(
+ minusOne.isEnable(), minusOne.getTtlSecond(), minusOne.getCapacity()));
+
+ CacheSpec minusTwo = CacheSpec.ofConnectorTtl(-2, 100);
+ Assertions.assertEquals(CacheSpec.CACHE_TTL_DISABLE_CACHE, minusTwo.getTtlSecond());
+ Assertions.assertFalse(CacheSpec.isCacheEnabled(
+ minusTwo.isEnable(), minusTwo.getTtlSecond(), minusTwo.getCapacity()));
+ }
+
@Test
public void isCacheEnabledMatchesLegacyFormula() {
Assertions.assertTrue(CacheSpec.isCacheEnabled(true, CacheSpec.CACHE_NO_TTL, 1));
diff --git a/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorMetadataCacheTest.java
similarity index 89%
rename from fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorPartitionViewCacheTest.java
rename to fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorMetadataCacheTest.java
index 95a80e5d6f164d..8a6c1596fccad4 100644
--- a/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorPartitionViewCacheTest.java
+++ b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorMetadataCacheTest.java
@@ -25,7 +25,7 @@
import java.util.concurrent.atomic.AtomicInteger;
/**
- * Unit tests for {@link ConnectorPartitionViewCache} — the GENERIC (engine-agnostic) cache A of the
+ * Unit tests for {@link ConnectorMetadataCache} — the GENERIC (engine-agnostic) cache A of the
* external-partition-derived-cache design (design doc {@code 2026-07-20-external-partition-derived-cache-design.md}
* §5). Mirrors {@link org.apache.doris.connector.iceberg.IcebergPartitionCache}'s test shape (that class is the
* iceberg-specific ancestor this generic framework is modeled on), but with a generic string value type ({@code V})
@@ -33,22 +33,22 @@
* per-table / per-db / whole-cache invalidation, and the disabled-cache bypass (both {@code enable=false} and
* {@code ttl-second=0}).
*/
-public class ConnectorPartitionViewCacheTest {
+public class ConnectorMetadataCacheTest {
private static final String ENGINE = "testengine";
- private static PartitionViewCacheKey key(String db, String table, long snapshotId, long schemaId) {
- return new PartitionViewCacheKey(db, table, snapshotId, schemaId);
+ private static ConnectorTableKey key(String db, String table, long snapshotId, long schemaId) {
+ return new ConnectorTableKey(db, table, snapshotId, schemaId);
}
- private static ConnectorPartitionViewCache newCache() {
- return new ConnectorPartitionViewCache<>(ENGINE, new HashMap<>());
+ private static ConnectorMetadataCache newCache() {
+ return new ConnectorMetadataCache<>(ENGINE, "partition_view", new HashMap<>());
}
@Test
public void missThenHitLoaderRunsOnceWithinTtl() {
AtomicInteger loads = new AtomicInteger();
- ConnectorPartitionViewCache cache = newCache();
+ ConnectorMetadataCache cache = newCache();
String first = cache.get(key("db", "t", 5L, 1L), () -> {
loads.incrementAndGet();
@@ -71,7 +71,7 @@ public void missThenHitLoaderRunsOnceWithinTtl() {
@Test
public void differentSnapshotIdIsADistinctEntry() {
AtomicInteger loads = new AtomicInteger();
- ConnectorPartitionViewCache cache = newCache();
+ ConnectorMetadataCache cache = newCache();
cache.get(key("db", "t", 1L, 1L), () -> {
loads.incrementAndGet();
@@ -91,7 +91,7 @@ public void differentSnapshotIdIsADistinctEntry() {
@Test
public void differentSchemaIdIsADistinctEntry() {
AtomicInteger loads = new AtomicInteger();
- ConnectorPartitionViewCache cache = newCache();
+ ConnectorMetadataCache cache = newCache();
cache.get(key("db", "t", 1L, 1L), () -> {
loads.incrementAndGet();
@@ -111,7 +111,7 @@ public void differentSchemaIdIsADistinctEntry() {
@Test
public void invalidateTableEvictsAllSnapshotsOfThatTableOnly() {
AtomicInteger loads = new AtomicInteger();
- ConnectorPartitionViewCache cache = newCache();
+ ConnectorMetadataCache cache = newCache();
cache.get(key("db", "t", 1L, 1L), () -> "v1");
cache.get(key("db", "t", 2L, 1L), () -> "v2");
@@ -142,7 +142,7 @@ public void invalidateTableEvictsAllSnapshotsOfThatTableOnly() {
@Test
public void invalidateDbClearsOnlyThatDbsTables() {
AtomicInteger loads = new AtomicInteger();
- ConnectorPartitionViewCache cache = newCache();
+ ConnectorMetadataCache cache = newCache();
cache.get(key("db1", "t1", 1L, 1L), () -> "a");
cache.get(key("db1", "t2", 1L, 1L), () -> "b");
@@ -171,7 +171,7 @@ public void invalidateDbClearsOnlyThatDbsTables() {
@Test
public void invalidateAllClearsEveryEntry() {
AtomicInteger loads = new AtomicInteger();
- ConnectorPartitionViewCache cache = newCache();
+ ConnectorMetadataCache cache = newCache();
cache.get(key("db1", "t1", 1L, 1L), () -> "a");
cache.get(key("db2", "t2", 1L, 1L), () -> "b");
@@ -191,7 +191,7 @@ public void enableFalseDisablesCacheAlwaysLoads() {
AtomicInteger loads = new AtomicInteger();
Map props = new HashMap<>();
props.put("meta.cache." + ENGINE + ".partition_view.enable", "false");
- ConnectorPartitionViewCache cache = new ConnectorPartitionViewCache<>(ENGINE, props);
+ ConnectorMetadataCache cache = new ConnectorMetadataCache<>(ENGINE, "partition_view", props);
String first = cache.get(key("db", "t", 5L, 1L), () -> {
loads.incrementAndGet();
@@ -215,7 +215,7 @@ public void ttlZeroDisablesCacheAlwaysLoads() {
AtomicInteger loads = new AtomicInteger();
Map props = new HashMap<>();
props.put("meta.cache." + ENGINE + ".partition_view.ttl-second", "0");
- ConnectorPartitionViewCache cache = new ConnectorPartitionViewCache<>(ENGINE, props);
+ ConnectorMetadataCache cache = new ConnectorMetadataCache<>(ENGINE, "partition_view", props);
cache.get(key("db", "t", 5L, 1L), () -> {
loads.incrementAndGet();
@@ -238,7 +238,7 @@ public void capacityZeroDisablesCacheAlwaysLoads() {
AtomicInteger loads = new AtomicInteger();
Map props = new HashMap<>();
props.put("meta.cache." + ENGINE + ".partition_view.capacity", "0");
- ConnectorPartitionViewCache cache = new ConnectorPartitionViewCache<>(ENGINE, props);
+ ConnectorMetadataCache cache = new ConnectorMetadataCache<>(ENGINE, "partition_view", props);
cache.get(key("db", "t", 5L, 1L), () -> {
loads.incrementAndGet();
@@ -259,13 +259,13 @@ public void defaultsAreEnabledWithNoProperties() {
// No meta.cache..partition_view.* properties set at all -> the built-in default (TTL 86400s,
// capacity 1000, ON) applies, matching IcebergPartitionCache's DEFAULT_TABLE_CACHE_CAPACITY. MUTATION:
// defaulting to disabled -> isEnabled() would be false here.
- ConnectorPartitionViewCache cache = new ConnectorPartitionViewCache<>(ENGINE, new HashMap<>());
+ ConnectorMetadataCache cache = new ConnectorMetadataCache<>(ENGINE, "partition_view", new HashMap<>());
Assertions.assertTrue(cache.isEnabled(), "the cache must be ON by default with no override properties");
}
@Test
public void loaderExceptionPropagatesUnwrappedAndIsNotCached() {
- ConnectorPartitionViewCache cache = newCache();
+ ConnectorMetadataCache cache = newCache();
// A failed load (e.g. a remote enumeration RPC failure) must propagate to the caller verbatim and must
// NOT poison the cache -- a transient failure should not make every subsequent query fail for the TTL.
Assertions.assertThrows(IllegalStateException.class, () -> cache.get(key("db", "t", 5L, 1L), () -> {
diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java
index 7f62f14aa7bf79..2db6f8d643cda8 100644
--- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java
@@ -32,6 +32,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
/**
* Metadata operations for Elasticsearch connector.
@@ -44,6 +45,14 @@ public class EsConnectorMetadata implements ConnectorMetadata {
private final EsConnectorRestClient restClient;
private final Map properties;
+ // ES-F3 per-statement schema memo. This metadata instance is created fresh per statement
+ // (funnel-memoized one-per-statement), so an index's mapping is resolved into columns once and
+ // reused, collapsing the repeated getColumnHandles->getTableSchema remote mapping fetches to one
+ // per index per statement. Read-only metadata -> no in-statement invalidation. ConcurrentHashMap
+ // to match the maxcompute handle-memo precedent (cheap defensiveness against any concurrent
+ // metadata access within a statement).
+ private final Map schemaMemo = new ConcurrentHashMap<>();
+
public EsConnectorMetadata(EsConnectorRestClient restClient,
Map properties) {
this.restClient = restClient;
@@ -82,15 +91,21 @@ public ConnectorTableSchema getTableSchema(
ConnectorSession session, ConnectorTableHandle handle) {
EsTableHandle esHandle = (EsTableHandle) handle;
String indexName = esHandle.getIndexName();
- String mapping = restClient.getMapping(indexName);
- boolean mappingEsId = Boolean.parseBoolean(properties.getOrDefault(
- EsConnectorProperties.MAPPING_ES_ID,
- EsConnectorProperties.MAPPING_ES_ID_DEFAULT));
-
- List columns = EsTypeMapping.parseMapping(
- indexName, mapping, mappingEsId);
- return new ConnectorTableSchema(indexName, columns, "ELASTICSEARCH",
- Collections.emptyMap());
+ return schemaMemo.computeIfAbsent(indexName, idx -> {
+ // Share the raw mapping with the scan path via the per-statement scope (ES-F2): one
+ // getMapping per index per statement across both paths. The schema memo above still
+ // collapses repeat getTableSchema calls within this metadata instance.
+ String mapping = EsStatementScope.sharedIndexMapping(
+ session, idx, () -> restClient.getMapping(idx));
+ boolean mappingEsId = Boolean.parseBoolean(properties.getOrDefault(
+ EsConnectorProperties.MAPPING_ES_ID,
+ EsConnectorProperties.MAPPING_ES_ID_DEFAULT));
+
+ List columns = EsTypeMapping.parseMapping(
+ idx, mapping, mappingEsId);
+ return new ConnectorTableSchema(idx, columns, "ELASTICSEARCH",
+ Collections.emptyMap());
+ });
}
@Override
@@ -138,7 +153,8 @@ public org.apache.doris.thrift.TTableDescriptor buildTableDescriptor(
* @param columnNames column names to resolve field contexts for
* @return fully populated EsMetadataState
*/
- public EsMetadataState fetchMetadataState(String indexName, List columnNames) {
+ public EsMetadataState fetchMetadataState(ConnectorSession session, String indexName,
+ List columnNames) {
String mappingType = properties.getOrDefault(
EsConnectorProperties.MAPPING_TYPE, null);
boolean nodesDiscovery = Boolean.parseBoolean(properties.getOrDefault(
@@ -149,7 +165,7 @@ public EsMetadataState fetchMetadataState(String indexName, List columnN
EsMetadataState state = new EsMetadataState(
indexName, mappingType, columnNames, nodesDiscovery, seeds);
- EsMetadataFetcher fetcher = new EsMetadataFetcher(restClient, state);
+ EsMetadataFetcher fetcher = new EsMetadataFetcher(restClient, state, session);
return fetcher.fetch();
}
@@ -164,6 +180,6 @@ public EsMetadataState fetchMetadataState(ConnectorSession session,
columnNames.add(col.getName());
}
EsTableHandle esHandle = (EsTableHandle) handle;
- return fetchMetadataState(esHandle.getIndexName(), columnNames);
+ return fetchMetadataState(session, esHandle.getIndexName(), columnNames);
}
}
diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMetadataFetcher.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMetadataFetcher.java
index f215a8125bea68..6e569abfd139f8 100644
--- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMetadataFetcher.java
+++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsMetadataFetcher.java
@@ -17,6 +17,8 @@
package org.apache.doris.connector.es;
+import org.apache.doris.connector.api.ConnectorSession;
+
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -39,10 +41,13 @@ public class EsMetadataFetcher {
private final EsConnectorRestClient restClient;
private final EsMetadataState state;
+ private final ConnectorSession session;
- public EsMetadataFetcher(EsConnectorRestClient restClient, EsMetadataState state) {
+ public EsMetadataFetcher(EsConnectorRestClient restClient, EsMetadataState state,
+ ConnectorSession session) {
this.restClient = restClient;
this.state = state;
+ this.session = session;
}
/**
@@ -55,7 +60,12 @@ public EsMetadataState fetch() {
}
private void fetchMapping() {
- String indexMapping = restClient.getMapping(state.getSourceIndex());
+ // Share the raw index mapping with the schema path via the per-statement scope: one
+ // getMapping per index per statement (ES-F2). The field-context derivation below stays
+ // per-scan (it depends on the projected columnNames).
+ String indexMapping = EsStatementScope.sharedIndexMapping(
+ session, state.getSourceIndex(),
+ () -> restClient.getMapping(state.getSourceIndex()));
EsFieldContext fieldContext = EsMappingUtils.resolveFieldContext(
state.getColumnNames(),
state.getSourceIndex(),
diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java
index 7b7607752ede72..0388312c17e328 100644
--- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java
+++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java
@@ -76,6 +76,15 @@ public class EsScanPlanProvider implements ConnectorScanPlanProvider {
private final EsConnectorRestClient restClient;
private final Map properties;
+ // ES-F1 per-scan hoist. planScan and buildScanNodeProperties of one scan node run on the same
+ // per-scan-node provider instance on the synchronous FE planning thread, and each used to fetch
+ // the full metadata state (mapping + shard routing + node topology) independently. Memoizing the
+ // last resolved state lets the second call reuse the first. Guarded on (index, columns) so a
+ // provider reused for a different request refetches; shard routing stays per-scan (fresh) because
+ // the provider is discarded at scan end. Plain field: safe ONLY because ES never enters batch mode
+ // (no off-thread scan pool) -- make it volatile if this provider ever declares batch scan.
+ private EsMetadataState memoizedState;
+
public EsScanPlanProvider(EsConnectorRestClient restClient,
Map properties) {
this.restClient = restClient;
@@ -96,7 +105,7 @@ public List planScan(
EsTableHandle esHandle = (EsTableHandle) handle;
String indexName = esHandle.getIndexName();
- EsMetadataState state = fetchMetadataState(esHandle, columns);
+ EsMetadataState state = fetchMetadataState(session, esHandle, columns);
EsShardPartitions shardPartitions = state.getShardPartitions();
if (shardPartitions == null) {
LOG.warn("No shard partitions found for index {}", indexName);
@@ -149,7 +158,7 @@ public Map getScanNodeProperties(
ConnectorTableHandle handle,
List columns,
Optional filter) {
- return buildScanNodeProperties(handle, columns, filter).getProperties();
+ return buildScanNodeProperties(session, handle, columns, filter).getProperties();
}
@Override
@@ -158,15 +167,16 @@ public ScanNodePropertiesResult getScanNodePropertiesResult(
ConnectorTableHandle handle,
List columns,
Optional filter) {
- return buildScanNodeProperties(handle, columns, filter);
+ return buildScanNodeProperties(session, handle, columns, filter);
}
private ScanNodePropertiesResult buildScanNodeProperties(
+ ConnectorSession session,
ConnectorTableHandle handle,
List columns,
Optional filter) {
EsTableHandle esHandle = (EsTableHandle) handle;
- EsMetadataState state = fetchMetadataState(esHandle, columns);
+ EsMetadataState state = fetchMetadataState(session, esHandle, columns);
Map nodeProps = new HashMap<>();
@@ -270,7 +280,7 @@ private EsQueryDslResult buildQueryDsl(Optional filter,
likePushDown, needCompatDateFields);
}
- private EsMetadataState fetchMetadataState(EsTableHandle handle,
+ private EsMetadataState fetchMetadataState(ConnectorSession session, EsTableHandle handle,
List columns) {
String indexName = handle.getIndexName();
List columnNames = new ArrayList<>();
@@ -279,6 +289,12 @@ private EsMetadataState fetchMetadataState(EsTableHandle handle,
columnNames.add(((NamedColumnHandle) col).getName());
}
}
+ EsMetadataState cached = memoizedState;
+ if (cached != null && cached.getSourceIndex().equals(indexName)
+ && cached.getColumnNames().equals(columnNames)) {
+ return cached;
+ }
+
String mappingType = properties.getOrDefault(
EsConnectorProperties.MAPPING_TYPE, null);
boolean nodesDiscovery = Boolean.parseBoolean(properties.getOrDefault(
@@ -289,8 +305,10 @@ private EsMetadataState fetchMetadataState(EsTableHandle handle,
EsMetadataState state = new EsMetadataState(
indexName, mappingType, columnNames, nodesDiscovery, seeds);
- EsMetadataFetcher fetcher = new EsMetadataFetcher(restClient, state);
- return fetcher.fetch();
+ EsMetadataFetcher fetcher = new EsMetadataFetcher(restClient, state, session);
+ state = fetcher.fetch();
+ memoizedState = state;
+ return state;
}
/**
diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsStatementScope.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsStatementScope.java
new file mode 100644
index 00000000000000..3ef6dec969873d
--- /dev/null
+++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsStatementScope.java
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.es;
+
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScopes;
+
+import java.util.function.Supplier;
+
+/**
+ * Per-statement scope helper for the ES connector: shares one index's raw mapping JSON across the
+ * schema path ({@link EsConnectorMetadata#getTableSchema}) and the scan path
+ * ({@link EsMetadataFetcher}) within a single statement. Both paths independently fetched the same
+ * {@code getMapping} remotely and derived different products from it (columns vs field-context);
+ * routing both through the shared statement scope collapses those to one remote fetch per index per
+ * statement while each path keeps its own derivation.
+ *
+ * Only the raw mapping — stable within a statement — is shared this way. Shard routing and node
+ * topology are freshness-sensitive (ES rebalances) and must stay per-scan; they are NOT shared here.
+ *
+ *
Under a {@code null} session or {@link org.apache.doris.connector.api.ConnectorStatementScope#NONE}
+ * (offline / no live statement) the loader runs on every call — byte-identical to fetching every time.
+ */
+final class EsStatementScope {
+
+ /**
+ * Namespace for es's per-statement raw index-mapping memo. Source-prefixed with the connector type ("es")
+ * so it stays distinct across a heterogeneous gateway; see {@link ConnectorStatementScopes}.
+ */
+ static final String INDEX_MAPPING_NAMESPACE = "es.index_mapping";
+
+ private EsStatementScope() {
+ }
+
+ static String sharedIndexMapping(ConnectorSession session, String indexName,
+ Supplier loader) {
+ return ConnectorStatementScopes.resolveInStatement(
+ session, INDEX_MAPPING_NAMESPACE,
+ EsConnectorMetadata.DEFAULT_DB, indexName, loader);
+ }
+}
diff --git a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java
index 9b204e55f213e3..3c9c24150196a5 100644
--- a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java
+++ b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java
@@ -18,6 +18,9 @@
package org.apache.doris.connector.es;
import org.apache.doris.connector.api.ConnectorContractValidator;
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScope;
+import org.apache.doris.connector.api.handle.NamedColumnHandle;
import org.apache.doris.connector.spi.ConnectorContext;
import org.junit.jupiter.api.Assertions;
@@ -26,7 +29,9 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
class EsScanPlanProviderTest {
@@ -38,6 +43,7 @@ static class CountingRestClient extends EsConnectorRestClient {
final AtomicInteger getMappingCount = new AtomicInteger();
final AtomicInteger searchShardsCount = new AtomicInteger();
+ final AtomicInteger getHttpNodesCount = new AtomicInteger();
CountingRestClient() {
super(new String[]{"localhost:9200"}, null, null, false, null);
@@ -46,8 +52,10 @@ static class CountingRestClient extends EsConnectorRestClient {
@Override
public String getMapping(String indexName) {
getMappingCount.incrementAndGet();
- // Minimal valid mapping JSON for EsMappingUtils.resolveFieldContext
- return "{\"" + indexName + "\":{\"mappings\":{\"properties\":{}}}}";
+ // Minimal valid mapping JSON for EsMappingUtils.resolveFieldContext; two keyword fields
+ // so tests that project columns (a/b) resolve a field context without erroring.
+ return "{\"" + indexName + "\":{\"mappings\":{\"properties\":"
+ + "{\"a\":{\"type\":\"keyword\"},\"b\":{\"type\":\"keyword\"}}}}}";
}
@Override
@@ -58,59 +66,89 @@ public EsShardPartitions searchShards(String indexName) {
@Override
public Map getHttpNodes() {
+ getHttpNodesCount.incrementAndGet();
Map nodes = new HashMap<>();
nodes.put("node1", new EsNodeInfo("node1", "localhost:9200"));
return nodes;
}
}
- private static final org.apache.doris.connector.api.ConnectorSession EMPTY_SESSION =
- new org.apache.doris.connector.api.ConnectorSession() {
- @Override
- public String getQueryId() {
- return "test-query";
- }
-
- @Override
- public String getUser() {
- return "test";
- }
-
- @Override
- public String getTimeZone() {
- return "UTC";
- }
-
- @Override
- public String getLocale() {
- return "en_US";
- }
-
- @Override
- public long getCatalogId() {
- return 0;
- }
-
- @Override
- public String getCatalogName() {
- return "test";
- }
-
- @Override
- public T getProperty(String name, Class type) {
- return null;
- }
-
- @Override
- public Map getCatalogProperties() {
- return Collections.emptyMap();
- }
-
- @Override
- public Map getSessionProperties() {
- return Collections.emptyMap();
- }
- };
+ /**
+ * Test session with a settable statement scope. Defaults to {@link ConnectorStatementScope#NONE}
+ * (the offline default); pass a real scope to exercise the per-statement cross-path mapping memo.
+ */
+ private static final class TestSession implements ConnectorSession {
+ private final ConnectorStatementScope scope;
+
+ TestSession(ConnectorStatementScope scope) {
+ this.scope = scope;
+ }
+
+ @Override
+ public String getQueryId() {
+ return "test-query";
+ }
+
+ @Override
+ public String getUser() {
+ return "test";
+ }
+
+ @Override
+ public String getTimeZone() {
+ return "UTC";
+ }
+
+ @Override
+ public String getLocale() {
+ return "en_US";
+ }
+
+ @Override
+ public long getCatalogId() {
+ return 0;
+ }
+
+ @Override
+ public String getCatalogName() {
+ return "test";
+ }
+
+ @Override
+ public T getProperty(String name, Class type) {
+ return null;
+ }
+
+ @Override
+ public Map getCatalogProperties() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Map getSessionProperties() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public ConnectorStatementScope getStatementScope() {
+ return scope;
+ }
+ }
+
+ /** A live per-statement scope: a plain CHM-backed arena, like the engine's real scope. */
+ private static ConnectorStatementScope liveScope() {
+ return new ConnectorStatementScope() {
+ private final Map arena = new ConcurrentHashMap<>();
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T computeIfAbsent(String key, Supplier loader) {
+ return (T) arena.computeIfAbsent(key, k -> loader.get());
+ }
+ };
+ }
+
+ private static final ConnectorSession EMPTY_SESSION = new TestSession(ConnectorStatementScope.NONE);
private static Map minimalProps() {
Map props = new HashMap<>();
@@ -119,7 +157,7 @@ private static Map minimalProps() {
}
@Test
- void testPlanScanAndScanNodePropertiesFetchIndependently() {
+ void testPlanScanAndScanNodePropertiesShareOneFetch() {
CountingRestClient client = new CountingRestClient();
EsScanPlanProvider provider = new EsScanPlanProvider(client, minimalProps());
EsTableHandle handle = new EsTableHandle("test_index");
@@ -127,10 +165,16 @@ void testPlanScanAndScanNodePropertiesFetchIndependently() {
provider.planScan(EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty());
provider.getScanNodeProperties(EMPTY_SESSION, handle, Collections.emptyList(),
java.util.Optional.empty());
- Assertions.assertEquals(2, client.getMappingCount.get(),
- "Each provider call should fetch mapping for the current request");
- Assertions.assertEquals(2, client.searchShardsCount.get(),
- "Each provider call should fetch shard routing for the current request");
+ // ES-F1: planScan and getScanNodeProperties of one scan node run on the SAME per-scan-node
+ // provider instance, so the metadata state (mapping + shard routing + node topology) is
+ // fetched once and shared -- not twice. MUTATION: removing the memoizedState guard makes
+ // each call refetch -> these go back to 2 -> red.
+ Assertions.assertEquals(1, client.getMappingCount.get(),
+ "the two provider calls of one scan node must share a single mapping fetch");
+ Assertions.assertEquals(1, client.searchShardsCount.get(),
+ "the two provider calls of one scan node must share a single shard-routing fetch");
+ Assertions.assertEquals(1, client.getHttpNodesCount.get(),
+ "the two provider calls of one scan node must share a single node-topology fetch");
}
@Test
@@ -141,10 +185,134 @@ void testDifferentIndexesFetchSeparately() {
Collections.emptyList(), java.util.Optional.empty());
provider.planScan(EMPTY_SESSION, new EsTableHandle("index_b"),
Collections.emptyList(), java.util.Optional.empty());
+ // The per-scan memo is guarded on the index, so a provider reused for a different index
+ // still refetches -- distinct indexes never share a memo entry.
Assertions.assertEquals(2, client.getMappingCount.get(),
"Different indexes should each fetch their own metadata");
}
+ @Test
+ void testSeparateProviderInstancesEachFetchForFreshness() {
+ // Each scan node gets its OWN provider instance. Shard routing must stay fresh per scan
+ // (ES rebalances), so the memo must live on the provider and never be reused across scans.
+ // Two providers for the same index each fetch once -> 2 total, proving the memo is per-scan,
+ // never cross-query.
+ CountingRestClient client = new CountingRestClient();
+ EsTableHandle handle = new EsTableHandle("test_index");
+
+ new EsScanPlanProvider(client, minimalProps())
+ .planScan(EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty());
+ new EsScanPlanProvider(client, minimalProps())
+ .planScan(EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty());
+
+ Assertions.assertEquals(2, client.searchShardsCount.get(),
+ "a separate scan node (provider) must refetch shard routing -- memo is per-scan, not cross-query");
+ }
+
+ @Test
+ void testMetadataSchemaMemoizedPerStatement() {
+ // ES-F3: EsConnectorMetadata is per-statement; an index's mapping is resolved into columns
+ // once and reused, so getTableSchema + the getColumnHandles that re-invokes it share one
+ // remote mapping fetch. MUTATION: dropping the schemaMemo makes each call refetch -> 2 -> red.
+ CountingRestClient client = new CountingRestClient();
+ EsConnectorMetadata metadata = new EsConnectorMetadata(client, minimalProps());
+ EsTableHandle handle = new EsTableHandle("test_index");
+
+ metadata.getTableSchema(EMPTY_SESSION, handle);
+ metadata.getColumnHandles(EMPTY_SESSION, handle);
+ Assertions.assertEquals(1, client.getMappingCount.get(),
+ "an index's schema must be resolved once per statement and reused");
+ }
+
+ @Test
+ void testMetadataSchemaFreshPerStatement() {
+ // A fresh EsConnectorMetadata (a new statement) must re-resolve -- the schema memo is
+ // per-statement, never cross-query (mappings can change between statements).
+ CountingRestClient client = new CountingRestClient();
+ EsTableHandle handle = new EsTableHandle("test_index");
+ new EsConnectorMetadata(client, minimalProps()).getTableSchema(EMPTY_SESSION, handle);
+ new EsConnectorMetadata(client, minimalProps()).getTableSchema(EMPTY_SESSION, handle);
+ Assertions.assertEquals(2, client.getMappingCount.get(),
+ "a new statement's metadata must re-resolve the schema -- memo is per-statement");
+ }
+
+ @Test
+ void testMappingSharedAcrossSchemaAndScanPathsWithinStatement() {
+ // ES-F2: the schema path (EsConnectorMetadata) and the scan path (EsScanPlanProvider) each
+ // fetched the same index mapping remotely. Routed through the shared per-statement scope, one
+ // index's getMapping fires ONCE across both paths within a statement. MUTATION: dropping the
+ // EsStatementScope wrapping (or a NONE scope) makes the paths fetch independently -> 2 -> red.
+ CountingRestClient client = new CountingRestClient();
+ ConnectorSession session = new TestSession(liveScope());
+ EsTableHandle handle = new EsTableHandle("test_index");
+
+ new EsConnectorMetadata(client, minimalProps()).getTableSchema(session, handle);
+ new EsScanPlanProvider(client, minimalProps())
+ .planScan(session, handle, Collections.emptyList(), java.util.Optional.empty());
+
+ Assertions.assertEquals(1, client.getMappingCount.get(),
+ "one index's mapping must be fetched once per statement across the schema and scan paths");
+ }
+
+ @Test
+ void testScopedMappingNotSharedAcrossStatements() {
+ // Two statements (distinct live scopes) must NOT share the mapping -- the scope memo is
+ // per-statement, never cross-query. Shard routing is likewise never placed in the scope.
+ CountingRestClient client = new CountingRestClient();
+ EsTableHandle handle = new EsTableHandle("test_index");
+
+ new EsConnectorMetadata(client, minimalProps())
+ .getTableSchema(new TestSession(liveScope()), handle);
+ new EsConnectorMetadata(client, minimalProps())
+ .getTableSchema(new TestSession(liveScope()), handle);
+
+ Assertions.assertEquals(2, client.getMappingCount.get(),
+ "a separate statement (scope) must re-fetch the mapping -- the scope memo is per-statement");
+ }
+
+ @Test
+ void testShardRoutingNeverSharedViaScope() {
+ // Hard freshness constraint: shard routing + node topology must NEVER be shared via the
+ // per-statement scope (ES rebalances) -- only the raw mapping is. Two scan providers sharing
+ // the SAME live statement scope and index each refetch shards/nodes, while the mapping is
+ // fetched once. MUTATION: routing searchShards or getHttpNodes through EsStatementScope would
+ // make the second scan reuse the first -> those counts drop to 1 -> red.
+ CountingRestClient client = new CountingRestClient();
+ ConnectorSession session = new TestSession(liveScope());
+ EsTableHandle handle = new EsTableHandle("test_index");
+
+ new EsScanPlanProvider(client, minimalProps())
+ .planScan(session, handle, Collections.emptyList(), java.util.Optional.empty());
+ new EsScanPlanProvider(client, minimalProps())
+ .planScan(session, handle, Collections.emptyList(), java.util.Optional.empty());
+
+ Assertions.assertEquals(2, client.searchShardsCount.get(),
+ "shard routing must be fetched per scan, never shared via the statement scope");
+ Assertions.assertEquals(2, client.getHttpNodesCount.get(),
+ "node topology must be fetched per scan, never shared via the statement scope");
+ Assertions.assertEquals(1, client.getMappingCount.get(),
+ "the raw mapping is the only thing shared across the two scans of one statement");
+ }
+
+ @Test
+ void testDifferentColumnsRefetch() {
+ // The per-scan memo is guarded on the projected columns, not just the index, because the
+ // field-context depends on them; a different projection must refetch rather than return a
+ // stale field-context. MUTATION: dropping the columns comparison lets the second projection
+ // reuse the first index's state -> searchShards stays 1 -> red.
+ CountingRestClient client = new CountingRestClient();
+ EsScanPlanProvider provider = new EsScanPlanProvider(client, minimalProps());
+ EsTableHandle handle = new EsTableHandle("test_index");
+
+ provider.planScan(EMPTY_SESSION, handle,
+ Collections.singletonList(new NamedColumnHandle("a")), java.util.Optional.empty());
+ provider.planScan(EMPTY_SESSION, handle,
+ Collections.singletonList(new NamedColumnHandle("b")), java.util.Optional.empty());
+
+ Assertions.assertEquals(2, client.searchShardsCount.get(),
+ "a different projection must refetch -- the memo is guarded on columns, not just index");
+ }
+
@Test
void testEsConnectorDoesNotSupportWrite() {
EsConnector connector = new EsConnector(minimalProps(), new ConnectorContext() {
diff --git a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsStatementScopeTest.java b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsStatementScopeTest.java
new file mode 100644
index 00000000000000..af679b6750e0ef
--- /dev/null
+++ b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsStatementScopeTest.java
@@ -0,0 +1,54 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.es;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+
+/**
+ * Guards that every ES statement-scope namespace follows the source-prefix norm: it is a compile-time constant
+ * prefixed with this connector's {@code ConnectorProvider.getType()} ("es"). Source-prefixing is what keeps the
+ * namespaces distinct from every other connector's on a heterogeneous gateway (no {@code ClassCastException} on the
+ * shared {@code (catalogId, db, table, queryId)} coordinate); the value-type home is
+ * {@link org.apache.doris.connector.api.ConnectorStatementScopes}.
+ */
+public class EsStatementScopeTest {
+
+ @Test
+ public void allNamespacesArePrefixedWithConnectorType() throws Exception {
+ // NORM (self-extending): reflect over every "*_NAMESPACE" constant this connector declares and assert each
+ // is prefixed with the connector's getType() ("es."). Reflecting means a NEW namespace is auto-covered; a
+ // forgotten prefix or a getType() drift turns this red with no test upkeep.
+ String prefix = new EsConnectorProvider().getType() + ".";
+ int checked = 0;
+ for (Field f : EsStatementScope.class.getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers()) && f.getType() == String.class
+ && f.getName().endsWith("_NAMESPACE")) {
+ f.setAccessible(true);
+ String ns = (String) f.get(null);
+ Assertions.assertTrue(ns.startsWith(prefix),
+ f.getName() + " (\"" + ns + "\") must be prefixed with the connector type \"" + prefix + "\"");
+ checked++;
+ }
+ }
+ Assertions.assertTrue(checked > 0, "expected at least one *_NAMESPACE constant to guard");
+ }
+}
diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java
index 223717a2bcb8f6..e41f981668c5cf 100644
--- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java
+++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java
@@ -29,7 +29,7 @@
import org.apache.doris.connector.api.procedure.ConnectorProcedureOps;
import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider;
import org.apache.doris.connector.api.write.ConnectorWritePlanProvider;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
import org.apache.doris.connector.hms.CachingHmsClient;
import org.apache.doris.connector.hms.HmsClient;
import org.apache.doris.connector.hms.HmsClientConfig;
@@ -98,7 +98,7 @@ public class HiveConnector implements Connector {
// metastore-metadata sibling is the CachingHmsClient wrapping the HmsClient.
private final HiveFileListingCache fileListingCache;
- // PERF-06 (S6): cross-query DERIVED partition-view cache ("cache A", the generic ConnectorPartitionViewCache
+ // PERF-06 (S6): cross-query DERIVED partition-view cache ("cache A", the generic ConnectorMetadataCache
// from fe-connector-cache), layered ABOVE the raw per-name HMS listing served by CachingHmsClient: it
// memoizes the BUILT List (HiveConnectorMetadata#listPartitionsUncached's per-name
// HiveWriteUtils.toPartitionValues parse + ConnectorPartitionInfo construction), keyed by
@@ -109,7 +109,7 @@ public class HiveConnector implements Connector {
// session=user / per-user credential-isolation cache-disabling convention (its per-catalog caches —
// CachingHmsClient, HiveFileListingCache — are already built unconditionally below), so this is constructed
// unconditionally too.
- private final ConnectorPartitionViewCache> partitionViewCache;
+ private final ConnectorMetadataCache> partitionViewCache;
// Embedded iceberg SIBLING connector: a flipped hms gateway delegates its iceberg-on-HMS tables to it. Built
// once per gateway connector (lazily) in the iceberg plugin's OWN child-first classloader via
@@ -131,7 +131,7 @@ public HiveConnector(Map properties, ConnectorContext context) {
this.fileListingCache = new HiveFileListingCache(this.properties);
// Reads its own meta.cache.hive.partition_view.(enable|ttl-second|capacity) from the catalog properties
// via the framework's CacheSpec (default ON / 24h / 1000).
- this.partitionViewCache = new ConnectorPartitionViewCache<>("hive", this.properties);
+ this.partitionViewCache = new ConnectorMetadataCache<>("hive", "partition_view", this.properties);
}
@Override
@@ -495,7 +495,7 @@ HiveFileListingCache fileListingCacheForTest() {
}
/** Test-only: the derived listPartitions view cache (PERF-06). Never null (hive has no session=user gate). */
- ConnectorPartitionViewCache> partitionViewCacheForTest() {
+ ConnectorMetadataCache> partitionViewCacheForTest() {
return partitionViewCache;
}
diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
index 0db7c4445d4949..ca93cd870309e6 100644
--- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
@@ -55,8 +55,8 @@
import org.apache.doris.connector.api.pushdown.ConnectorLiteral;
import org.apache.doris.connector.api.pushdown.FilterApplicationResult;
import org.apache.doris.connector.api.scan.ConnectorPartitionValues;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
-import org.apache.doris.connector.cache.PartitionViewCacheKey;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
+import org.apache.doris.connector.cache.ConnectorTableKey;
import org.apache.doris.connector.hms.HiveShowCreateTableRenderer;
import org.apache.doris.connector.hms.HmsClient;
import org.apache.doris.connector.hms.HmsClientException;
@@ -229,7 +229,7 @@ public class HiveConnectorMetadata implements ConnectorMetadata {
// default (harmless for the direct-construction tests, which inject their file sizes and never list).
private final HiveFileListingCache fileListingCache;
- // PERF-06 (S6): cross-query DERIVED partition-view cache A (generic ConnectorPartitionViewCache), injected by
+ // PERF-06 (S6): cross-query DERIVED partition-view cache A (generic ConnectorMetadataCache), injected by
// the owning HiveConnector; null = no cross-query derived layer (the convenience/test ctors below pass null,
// matching every existing direct-construction test). Layered ABOVE the raw per-name HMS listing served by
// CachingHmsClient: a hit skips both the derived-view BUILD (the per-name HiveWriteUtils.toPartitionValues
@@ -238,7 +238,7 @@ public class HiveConnectorMetadata implements ConnectorMetadata {
// handle carries no schema version, so both axes are pinned "unversioned". Consumed only by listPartitions:
// getMvccPartitionView returns Optional.empty() for a real hive handle (the SPI default), so fe-core's generic
// MTMV model already falls back to listPartitions for hive — there is no second enumeration hook to wrap.
- private final ConnectorPartitionViewCache> partitionViewCache;
+ private final ConnectorMetadataCache> partitionViewCache;
public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context) {
this(hmsClient, properties, context, NO_ICEBERG_SIBLING, NO_HUDI_SIBLING, NO_SIBLING_OWNER);
@@ -273,7 +273,7 @@ public HiveConnectorMetadata(HmsClient hmsClient, Map properties
Supplier hudiSiblingSupplier,
Function siblingOwnerResolver,
HiveFileListingCache fileListingCache,
- ConnectorPartitionViewCache> partitionViewCache) {
+ ConnectorMetadataCache> partitionViewCache) {
this.hmsClient = hmsClient;
this.properties = properties;
this.context = context;
@@ -1167,7 +1167,7 @@ public List listPartitions(ConnectorSession session,
if (partitionViewCache == null || filter.isPresent()) {
return listPartitionsUncached(hiveHandle);
}
- PartitionViewCacheKey key = new PartitionViewCacheKey(
+ ConnectorTableKey key = new ConnectorTableKey(
hiveHandle.getDbName(), hiveHandle.getTableName(), -1L, -1L);
return partitionViewCache.get(key, () -> listPartitionsUncached(hiveHandle));
}
diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionViewCacheTest.java
index 3b375114572190..e18487005642f4 100644
--- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionViewCacheTest.java
+++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionViewCacheTest.java
@@ -23,7 +23,7 @@
import org.apache.doris.connector.api.pushdown.ConnectorComparison;
import org.apache.doris.connector.api.pushdown.ConnectorExpression;
import org.apache.doris.connector.api.pushdown.ConnectorLiteral;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
import org.apache.doris.connector.hms.HmsClient;
import org.apache.doris.connector.hms.HmsDatabaseInfo;
import org.apache.doris.connector.hms.HmsPartitionInfo;
@@ -41,7 +41,7 @@
/**
* PERF-06 (S6) tests for the cross-query DERIVED partition-view cache ("cache A", the generic
- * {@link ConnectorPartitionViewCache}) wired into {@link HiveConnectorMetadata#listPartitions}. Hive does NOT
+ * {@link ConnectorMetadataCache}) wired into {@link HiveConnectorMetadata#listPartitions}. Hive does NOT
* override {@code getMvccPartitionView} for a real hive handle (it returns the SPI default
* {@code Optional.empty()} — fe-core's generic MTMV model already falls back to {@code listPartitions}), so —
* like paimon's single typed field — there is exactly ONE enumeration hook to wrap.
@@ -66,7 +66,7 @@ public class HiveConnectorMetadataPartitionViewCacheTest {
"year=2024/month=01");
private static HiveConnectorMetadata metadataWithCache(CountingHmsClient client,
- ConnectorPartitionViewCache> cache) {
+ ConnectorMetadataCache> cache) {
return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext(),
() -> {
throw new UnsupportedOperationException();
@@ -80,8 +80,8 @@ private static HiveConnectorMetadata metadataWithCache(CountingHmsClient client,
new HiveFileListingCache(Collections.emptyMap()), cache);
}
- private static ConnectorPartitionViewCache> partitionViewCache() {
- return new ConnectorPartitionViewCache<>("hive", Collections.emptyMap());
+ private static ConnectorMetadataCache> partitionViewCache() {
+ return new ConnectorMetadataCache<>("hive", "partition_view", Collections.emptyMap());
}
private static HiveTableHandle handle() {
@@ -101,7 +101,7 @@ public void listPartitionsCachesDerivedListAcrossQueries() {
// round-trip. MUTATION: not consulting the cache (compute directly every call) -> the seam runs twice
// -> red.
CountingHmsClient client = new CountingHmsClient(PARTITIONS);
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
HiveConnectorMetadata md = metadataWithCache(client, cache);
HiveTableHandle h = handle();
@@ -120,7 +120,7 @@ public void listPartitionsInvalidateTableForcesReEnumeration() {
// so the next query re-enumerates live. MUTATION: invalidateTable not wired -> the second call hits the
// stale entry -> listPartitionNamesCalls stays 1 -> red.
CountingHmsClient client = new CountingHmsClient(PARTITIONS);
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
HiveConnectorMetadata md = metadataWithCache(client, cache);
HiveTableHandle h = handle();
@@ -135,7 +135,7 @@ public void listPartitionsInvalidateAllForcesReEnumeration() {
// WHY: REFRESH CATALOG (HiveConnector.invalidateAll -> cache.invalidateAll) must drop the cached list.
// MUTATION: invalidateAll not wired -> the second call hits -> listPartitionNamesCalls stays 1 -> red.
CountingHmsClient client = new CountingHmsClient(PARTITIONS);
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
HiveConnectorMetadata md = metadataWithCache(client, cache);
HiveTableHandle h = handle();
@@ -155,7 +155,7 @@ public void listPartitionsWithFilterBypassesCache() {
// the second filtered call hits (count stays 1) -> red; or a bypassed call populating the cache -> the
// following empty-filter call hits instead of missing -> red.
CountingHmsClient client = new CountingHmsClient(PARTITIONS);
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
HiveConnectorMetadata md = metadataWithCache(client, cache);
HiveTableHandle h = handle();
diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPartitionViewCacheTest.java
index 57c765f9482e90..a13c072f0c26e2 100644
--- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPartitionViewCacheTest.java
+++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPartitionViewCacheTest.java
@@ -18,8 +18,8 @@
package org.apache.doris.connector.hive;
import org.apache.doris.connector.api.ConnectorPartitionInfo;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
-import org.apache.doris.connector.cache.PartitionViewCacheKey;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
+import org.apache.doris.connector.cache.ConnectorTableKey;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -63,16 +63,16 @@ public void refreshHooksInvalidatePartitionViewCache() {
// loader must run again. MUTATION: an invalidate* hook not routed to the view cache -> the entry
// survives -> loader not re-run -> a loads assert below red.
HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext());
- ConnectorPartitionViewCache> cache = connector.partitionViewCacheForTest();
+ ConnectorMetadataCache> cache = connector.partitionViewCacheForTest();
Assertions.assertNotNull(cache);
int[] loads = {0};
Supplier> loader = () -> {
loads[0]++;
return Collections.emptyList();
};
- PartitionViewCacheKey db1t1 = new PartitionViewCacheKey("db1", "t1", -1L, -1L);
- PartitionViewCacheKey db1t2 = new PartitionViewCacheKey("db1", "t2", -1L, -1L);
- PartitionViewCacheKey db2t1 = new PartitionViewCacheKey("db2", "t1", -1L, -1L);
+ ConnectorTableKey db1t1 = new ConnectorTableKey("db1", "t1", -1L, -1L);
+ ConnectorTableKey db1t2 = new ConnectorTableKey("db1", "t2", -1L, -1L);
+ ConnectorTableKey db2t1 = new ConnectorTableKey("db2", "t1", -1L, -1L);
// REFRESH TABLE db1.t1 -> only db1.t1 re-loads. Uses the public no-client hook (mirrors a never-scanned
// catalog's REFRESH — hmsClient is null here).
@@ -108,14 +108,14 @@ public void invalidatePartitionDropsTheWholeTablesCachedView() {
// it must invalidate that whole entry. MUTATION: invalidatePartition not routed to the view cache -> the
// stale entry survives -> the reload assert below red.
HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext());
- ConnectorPartitionViewCache> cache = connector.partitionViewCacheForTest();
+ ConnectorMetadataCache> cache = connector.partitionViewCacheForTest();
int[] loads = {0};
Supplier> loader = () -> {
loads[0]++;
return Collections.emptyList();
};
- PartitionViewCacheKey db1t1 = new PartitionViewCacheKey("db1", "t1", -1L, -1L);
- PartitionViewCacheKey db1t2 = new PartitionViewCacheKey("db1", "t2", -1L, -1L);
+ ConnectorTableKey db1t1 = new ConnectorTableKey("db1", "t1", -1L, -1L);
+ ConnectorTableKey db1t2 = new ConnectorTableKey("db1", "t2", -1L, -1L);
cache.get(db1t1, loader);
cache.get(db1t2, loader);
diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java
index f00bcbc1fe56c0..185acd5437b716 100644
--- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java
+++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnector.java
@@ -23,6 +23,7 @@
import org.apache.doris.connector.api.DorisConnectorException;
import org.apache.doris.connector.api.handle.ConnectorTableHandle;
import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider;
+import org.apache.doris.connector.hms.CachingHmsClient;
import org.apache.doris.connector.hms.HmsClient;
import org.apache.doris.connector.hms.HmsClientConfig;
import org.apache.doris.connector.hms.ThriftHmsClient;
@@ -137,6 +138,60 @@ public ConnectorScanPlanProvider getScanPlanProvider() {
return new HudiScanPlanProvider(properties, context);
}
+ /**
+ * REFRESH TABLE hook: flush this table's cached HMS metadata ({@link CachingHmsClient#flush}: table info +
+ * partition names) so the next query re-reads it live. Reads the client field WITHOUT building it
+ * (getOrCreateClient would force a real client just to flush an empty cache; a never-queried catalog has no
+ * cache to flush). hudi is a leaf sibling (no siblings of its own) holding no file/partition-view caches, so
+ * the metastore flush is the only layer. The hive gateway forwards REFRESH to this sibling via
+ * {@code forEachBuiltSibling}, so this override is what makes REFRESH reach the sibling's own client
+ * (fe-core routes REFRESH TABLE to {@code connector.invalidateTable} for a plugin-driven catalog).
+ */
+ @Override
+ public void invalidateTable(String dbName, String tableName) {
+ invalidateTable(hmsClient, dbName, tableName);
+ }
+
+ // Package-private seam: a unit test can pass an observable CachingHmsClient (the hmsClient field is
+ // otherwise only set by getOrCreateClient building a real pooled client).
+ void invalidateTable(HmsClient client, String dbName, String tableName) {
+ if (client instanceof CachingHmsClient) {
+ ((CachingHmsClient) client).flush(dbName, tableName);
+ }
+ }
+
+ /**
+ * REFRESH DATABASE hook: flush every cached table in this database ({@link CachingHmsClient#flushDb}). Same
+ * no-force-build read of the client as {@link #invalidateTable(String, String)}.
+ */
+ @Override
+ public void invalidateDb(String dbName) {
+ invalidateDb(hmsClient, dbName);
+ }
+
+ // Package-private seam (see invalidateTable above).
+ void invalidateDb(HmsClient client, String dbName) {
+ if (client instanceof CachingHmsClient) {
+ ((CachingHmsClient) client).flushDb(dbName);
+ }
+ }
+
+ /**
+ * REFRESH CATALOG hook: flush this catalog's entire HMS metadata cache ({@link CachingHmsClient#flushAll}).
+ * Same no-force-build read of the client as {@link #invalidateTable(String, String)}.
+ */
+ @Override
+ public void invalidateAll() {
+ invalidateAll(hmsClient);
+ }
+
+ // Package-private seam (see invalidateTable above).
+ void invalidateAll(HmsClient client) {
+ if (client instanceof CachingHmsClient) {
+ ((CachingHmsClient) client).flushAll();
+ }
+ }
+
private HmsClient getOrCreateClient() {
if (hmsClient == null) {
synchronized (this) {
@@ -183,7 +238,21 @@ public T execute(Callable callable) throws Exception {
} else {
authAction = context::executeAuthenticated;
}
- return new ThriftHmsClient(config, authAction);
+ return wrapWithCache(new ThriftHmsClient(config, authAction));
+ }
+
+ /**
+ * Wraps the raw pooled client in the shared {@link CachingHmsClient} (mirrors {@code HiveConnector}):
+ * {@code getTable} and {@code listPartitionNames} become {@code (db,table)}-keyed and TTL-bounded
+ * ({@code meta.cache.hive.*}, default 24h), so repeated queries against the same hudi table stop re-hitting
+ * HMS; {@code tableExists}/{@code listTables} stay pass-through. Freshness is preserved two ways: the
+ * SHOW-PARTITIONS / {@code partition_values} path lists FRESH (bypasses the cache — see
+ * {@link HudiConnectorMetadata}{@code .collectPartitions}), and REFRESH flushes it (see
+ * {@link #invalidateTable(String, String)}). Package-private so a unit test can wrap an observable fake and
+ * assert the cache decoration.
+ */
+ HmsClient wrapWithCache(HmsClient raw) {
+ return new CachingHmsClient(raw, properties);
}
/**
diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java
index b4af4f8f2e0249..d443fab976c140 100644
--- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java
@@ -317,7 +317,7 @@ public ConnectorTableSchema getTableSchema(
ConnectorSession session, ConnectorTableHandle handle) {
// Latest schema (no time-travel pin). Shares the single build path with the at-instant overload below
// (null instant = latest) so the two can never drift.
- return buildTableSchema((HudiTableHandle) handle, null);
+ return buildTableSchema(session, (HudiTableHandle) handle, null);
}
/**
@@ -336,30 +336,51 @@ public ConnectorTableSchema getTableSchema(
public ConnectorTableSchema getTableSchema(ConnectorSession session,
ConnectorTableHandle handle, ConnectorMvccSnapshot snapshot) {
HudiTableHandle hudiHandle = (HudiTableHandle) handle;
- return buildTableSchema(hudiHandle, hudiHandle.getQueryInstant());
+ return buildTableSchema(session, hudiHandle, hudiHandle.getQueryInstant());
}
/**
- * Single build path for {@link #getTableSchema}: reads the columns from the Hudi metaClient AS OF
- * {@code queryInstant} ({@code null} = latest) and wraps them in a {@link ConnectorTableSchema}. Shared by
- * the latest (2-arg) and at-instant (3-arg) entry points so they cannot diverge.
+ * Single build path for {@link #getTableSchema}: reads the columns AS OF {@code queryInstant}
+ * ({@code null} = latest) and wraps them in a {@link ConnectorTableSchema}. Shared by the latest (2-arg) and
+ * at-instant (3-arg) entry points so they cannot diverge.
+ *
+ * The latest branch ({@code queryInstant == null}) resolves the columns through the per-statement latest-schema
+ * memo ({@link HudiStatementScope#sharedLatestColumns}), so repeated latest reads in one statement share a single
+ * {@link #getSchemaFromMetaClient} call; value and failure semantics are byte-identical to the un-memoized read
+ * (that method already swallows a failure to an empty list / BE BY_NAME). The at-instant branch
+ * ({@code FOR TIME AS OF}) stays on {@link #getSchemaFromMetaClient}, UNMEMOIZED: the memo key does not carry the
+ * instant, so a latest read and an at-instant read of the same table in one statement must not share it.
*/
- private ConnectorTableSchema buildTableSchema(HudiTableHandle hudiHandle, String queryInstant) {
+ private ConnectorTableSchema buildTableSchema(ConnectorSession session, HudiTableHandle hudiHandle,
+ String queryInstant) {
String basePath = hudiHandle.getBasePath();
List columns;
if (basePath != null && !basePath.isEmpty()) {
- columns = getSchemaFromMetaClient(basePath, queryInstant);
+ if (queryInstant == null) {
+ columns = HudiStatementScope.sharedLatestColumns(session, hudiHandle.getDbName(),
+ hudiHandle.getTableName(), () -> getSchemaFromMetaClient(basePath, null));
+ } else {
+ columns = getSchemaFromMetaClient(basePath, queryInstant);
+ }
} else {
columns = getSchemaFromHms(hudiHandle.getDbName(), hudiHandle.getTableName());
}
+ return assembleTableSchema(hudiHandle, columns);
+ }
+
+ /**
+ * Wraps the resolved columns in a {@link ConnectorTableSchema}, stamping the hudi table-type and the
+ * partition-column marker fe-core needs to model the table as partitioned (parity with the OLD
+ * HMSExternalTable/HudiDlaTable path, which read the HMS partition keys). The keys already live on the handle
+ * (getTableHandle -> tableInfo.getPartitionKeys()); emit them as the RAW-name CSV, matching the schema column
+ * names (the partition columns are appended to {@code columns} by both schema sources). Shared by the memoized
+ * latest path and the at-instant {@link #buildTableSchema} path.
+ */
+ private ConnectorTableSchema assembleTableSchema(HudiTableHandle hudiHandle, List columns) {
Map tableProperties = new HashMap<>();
tableProperties.put("hudi.table.type", hudiHandle.getHudiTableType());
- // Stamp the partition-column marker fe-core needs to model the table as partitioned (parity with the OLD
- // HMSExternalTable/HudiDlaTable path, which read the HMS partition keys). The keys already live on the
- // handle (getTableHandle -> tableInfo.getPartitionKeys()); emit them as the RAW-name CSV, matching the
- // schema column names (the partition columns are appended to `columns` by both schema sources).
List partitionKeyNames = hudiHandle.getPartitionKeyNames();
if (partitionKeyNames != null && !partitionKeyNames.isEmpty()) {
tableProperties.put(PARTITION_COLUMNS_PROPERTY, String.join(",", partitionKeyNames));
@@ -389,8 +410,8 @@ public Map getProperties() {
* default delegates here). It is deliberately NOT placed on {@code getWritePlanProvider}: the admission gate
* calls {@code getWritePlanProvider} to DECIDE, so throwing there would make the gate throw a {@code
* DorisConnectorException} the engine misclassifies as an internal error instead of the clean "does not
- * support INSERT" — keep the provider {@code null} and reject explicitly only here (dormant until hms enters
- * {@code SPI_READY_TYPES}).
+ * support INSERT" — keep the provider {@code null} and reject explicitly only here (with hms live, a user
+ * write against a hudi table reaches this reject on the gateway path).
*/
@Override
public ConnectorTransaction beginTransaction(ConnectorSession session) {
@@ -415,7 +436,11 @@ public ConnectorTransaction beginTransaction(ConnectorSession session) {
@Override
public Optional beginQuerySnapshot(
ConnectorSession session, ConnectorTableHandle handle) {
- return buildBeginQuerySnapshot(latestInstant((HudiTableHandle) handle));
+ HudiTableHandle hudiHandle = (HudiTableHandle) handle;
+ // Memoized per statement so repeated snapshot pins share one instant read; the loader IS latestInstant, so
+ // the value (and its fail-loud-on-error semantics) is byte-identical to the un-memoized read.
+ return buildBeginQuerySnapshot(HudiStatementScope.sharedLatestInstant(
+ session, hudiHandle.getDbName(), hudiHandle.getTableName(), () -> latestInstant(hudiHandle)));
}
/** Builds the query-begin snapshot from a pinned instant. Static for offline unit testing. */
@@ -645,12 +670,15 @@ public List getSyntheticScanPredicates(ConnectorSession ses
@Override
public List listPartitions(ConnectorSession session,
ConnectorTableHandle handle, Optional filter) {
- return collectPartitions((HudiTableHandle) handle);
+ // Query-pruning / MTMV enumeration: read the cache (bypassCache=false).
+ return collectPartitions((HudiTableHandle) handle, false);
}
@Override
public List listPartitionNames(ConnectorSession session, ConnectorTableHandle handle) {
- List partitions = collectPartitions((HudiTableHandle) handle);
+ // SHOW PARTITIONS / partitions() TVF: list FRESH (bypassCache=true) so a newly hive-synced partition is
+ // visible immediately, not up to a TTL later.
+ List partitions = collectPartitions((HudiTableHandle) handle, true);
List names = new ArrayList<>(partitions.size());
for (ConnectorPartitionInfo partition : partitions) {
names.add(partition.getPartitionName());
@@ -661,7 +689,8 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH
@Override
public List> listPartitionValues(ConnectorSession session,
ConnectorTableHandle handle, List partitionColumns) {
- List partitions = collectPartitions((HudiTableHandle) handle);
+ // partition_values() TVF (user-facing enumeration): list FRESH (bypassCache=true), like listPartitionNames.
+ List partitions = collectPartitions((HudiTableHandle) handle, true);
List> result = new ArrayList<>(partitions.size());
for (ConnectorPartitionInfo partition : partitions) {
Map rawValues = partition.getPartitionValues();
@@ -684,7 +713,7 @@ public List> listPartitionValues(ConnectorSession session,
* partition. Unpartitioned → {@code emptyList()} (legacy never lists partitions for an unpartitioned
* table). Explicit time-travel (non-latest) partition listing is a later step.
*/
- private List collectPartitions(HudiTableHandle handle) {
+ private List collectPartitions(HudiTableHandle handle, boolean bypassCache) {
List partKeyNames = handle.getPartitionKeyNames();
if (partKeyNames == null || partKeyNames.isEmpty()) {
return Collections.emptyList();
@@ -693,8 +722,13 @@ private List collectPartitions(HudiTableHandle handle) {
// hive-sync tables register their partitions in HMS: list the names from there (already authed via
// hmsClient, no metaClient), like legacy. The instant still comes from the timeline. If HMS has none
// (a hive-sync table not yet synced), fall back to the hudi metadata listing (legacy parity).
- List hmsNames = hmsClient.listPartitionNames(
- handle.getDbName(), handle.getTableName(), -1);
+ // bypassCache selects the freshness contract (mirrors HiveConnectorMetadata.collectPartitionNames):
+ // the SHOW-PARTITIONS / partition_values-TVF path (listPartitionNames / listPartitionValues) lists
+ // FRESH — legacy read the raw pooled client, so an externally hive-synced partition must not stay
+ // invisible until TTL/REFRESH — while the query-pruning / MTMV path (listPartitions) reads the cache.
+ List hmsNames = bypassCache
+ ? hmsClient.listPartitionNamesFresh(handle.getDbName(), handle.getTableName(), -1)
+ : hmsClient.listPartitionNames(handle.getDbName(), handle.getTableName(), -1);
if (hmsNames != null && !hmsNames.isEmpty()) {
return buildPartitionInfos(hmsNames, partKeyNames, latestInstant(handle));
}
@@ -744,8 +778,11 @@ static List buildPartitionInfos(
return result;
}
- /** Pins the latest completed instant, building the metaClient under the plugin auth + TCCL pin. */
- private long latestInstant(HudiTableHandle handle) {
+ /**
+ * Pins the latest completed instant, building the metaClient under the plugin auth + TCCL pin. Package-private so
+ * a same-loader unit test can override it to count instant reads without a live metaClient.
+ */
+ long latestInstant(HudiTableHandle handle) {
return metaClientExecutor.execute(() ->
HudiScanPlanProvider.latestCompletedInstant(
HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), handle.getBasePath())));
diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java
index 9d65299509ea91..d115722773e79a 100644
--- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java
+++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java
@@ -247,7 +247,7 @@ public List planScan(
// window onto the handle). Select the files the window touches via the ported IncrementalRelation family
// instead of the latest-snapshot partition scan below. NOTE: this selects FILES only — row-level
// filtering to (begin, end] is a LATER step (an FE-side synthetic _hoodie_commit_time predicate), so a
- // bare @incr read would over-read until that lands (harmless while the connector is dormant). The COW-vs-
+ // bare @incr read would over-read until that lands (a tracked gap on the now-live path). The COW-vs-
// MOR relation choice is driven by the SAME isCow the snapshot path uses (metaClient.getTableType(),
// hoodie.properties-authoritative): this SUBSUMES the legacy RO-as-RT flip (LogicalHudiScan:251-260 /
// HudiScanNode:187-199), which existed only because legacy classifies from the hive inputFormat (a MOR
diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiStatementScope.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiStatementScope.java
new file mode 100644
index 00000000000000..708400ae2394e6
--- /dev/null
+++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiStatementScope.java
@@ -0,0 +1,78 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.hudi;
+
+import org.apache.doris.connector.api.ConnectorColumn;
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScope;
+import org.apache.doris.connector.api.ConnectorStatementScopes;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * Connector-private helpers over the neutral {@link ConnectorStatementScope} (reached via
+ * {@link ConnectorSession#getStatementScope()}), giving hudi one place to key its per-statement metaClient memos.
+ *
+ * Two INDEPENDENT memos, so a statement builds each latest fact at most once and the two never couple: the
+ * latest-schema read ({@code getTableSchema} with no time-travel pin) resolves through {@link #sharedLatestColumns},
+ * and the query-snapshot pin ({@code beginQuerySnapshot}) through {@link #sharedLatestInstant}. Each loader is the
+ * connector's existing single-fact method, so the memo only collapses REPEATED same-fact resolves within a statement
+ * and leaves each fact's value / failure semantics byte-identical. Distinct namespaces
+ * ({@link #LATEST_SCHEMA_NAMESPACE} vs {@link #LATEST_INSTANT_NAMESPACE}) keep the two value types (columns vs
+ * instant) from colliding on the shared {@code (catalog, db, table, queryId)} coordinate.
+ *
+ * Under {@link ConnectorStatementScope#NONE} (offline planning / no live statement) or a {@code null} session each
+ * loader runs every time — byte-identical to resolving the fact per call, as before the memo.
+ */
+final class HudiStatementScope {
+
+ /**
+ * Namespace for hudi's per-statement latest-columns memo. Source-prefixed with the connector type ("hudi")
+ * so it stays distinct across a heterogeneous gateway; see {@link ConnectorStatementScopes}.
+ */
+ static final String LATEST_SCHEMA_NAMESPACE = "hudi.latest_schema";
+
+ /** Namespace for hudi's per-statement latest-completed-instant memo. Source-prefixed with the type ("hudi"). */
+ static final String LATEST_INSTANT_NAMESPACE = "hudi.latest_instant";
+
+ private HudiStatementScope() {}
+
+ /**
+ * Resolves the latest columns for {@code db.table} once per statement, sharing the single result across every
+ * latest-schema resolver of the statement. {@code loader} (the connector's existing latest-schema read) runs at
+ * most once per statement; the key carries the catalog id and queryId (cross-catalog / prepared-execution
+ * isolation).
+ */
+ static List sharedLatestColumns(ConnectorSession session, String dbName, String tableName,
+ Supplier> loader) {
+ return ConnectorStatementScopes.resolveInStatement(
+ session, LATEST_SCHEMA_NAMESPACE, dbName, tableName, loader);
+ }
+
+ /**
+ * Resolves the latest completed instant for {@code db.table} once per statement, sharing the single value across
+ * every snapshot-pin resolver of the statement. {@code loader} (the connector's existing instant read) runs at
+ * most once per statement.
+ */
+ static long sharedLatestInstant(ConnectorSession session, String dbName, String tableName,
+ Supplier loader) {
+ return ConnectorStatementScopes.resolveInStatement(
+ session, LATEST_INSTANT_NAMESPACE, dbName, tableName, loader);
+ }
+}
diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorHmsCacheTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorHmsCacheTest.java
new file mode 100644
index 00000000000000..4efa41ce7c4755
--- /dev/null
+++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorHmsCacheTest.java
@@ -0,0 +1,265 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.hudi;
+
+import org.apache.doris.connector.hms.CachingHmsClient;
+import org.apache.doris.connector.hms.HmsClient;
+import org.apache.doris.connector.hms.HmsDatabaseInfo;
+import org.apache.doris.connector.hms.HmsPartitionInfo;
+import org.apache.doris.connector.hms.HmsTableInfo;
+import org.apache.doris.connector.spi.ConnectorContext;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+
+/**
+ * Locks the HMS-caching layer added to the hudi connector (the least-cached SPI connector), which mirrors
+ * {@code HiveConnector} so repeated queries against the same hudi-on-HMS table stop re-hitting the metastore.
+ * Two behaviors carry correctness weight and are pinned here (Rule 9):
+ *
+ * - Fresh vs cached split. The user-facing enumeration paths ({@code SHOW PARTITIONS} =
+ * {@code listPartitionNames}, {@code partition_values()} TVF = {@code listPartitionValues}) MUST list
+ * FRESH (bypass the cache), or an externally hive-synced partition stays invisible until the 24h TTL —
+ * a freshness regression the raw pre-cache client never had. The query-pruning / MTMV path
+ * ({@code listPartitions}) MUST use the cache (that is the whole point of wrapping).
+ * - REFRESH flush. {@code invalidateTable}/{@code invalidateDb}/{@code invalidateAll} MUST flush the
+ * {@link CachingHmsClient}, or REFRESH cannot clear the sibling's own cache (the gateway forwards REFRESH
+ * to the sibling, so the flush must land on THIS connector's client).
+ *
+ */
+public class HudiConnectorHmsCacheTest {
+
+ private static final List YEAR_MONTH = Arrays.asList("year", "month");
+ private static final List ONE_PARTITION = Collections.singletonList("year=2024/month=01");
+
+ // ── wrap ─────────────────────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void wrapWithCacheReturnsCachingHmsClient() {
+ // MUTATION: returning the raw client (dropping the wrap) -> not a CachingHmsClient -> red. This is the
+ // guard for HudiConnector.createClient wrapping the pooled ThriftHmsClient before handing it out.
+ HmsClient wrapped = connector().wrapWithCache(new FakeHmsClient(ONE_PARTITION));
+ Assertions.assertTrue(wrapped instanceof CachingHmsClient,
+ "the hudi HMS client must be decorated with CachingHmsClient");
+ }
+
+ // ── fresh vs cached split (hive-sync partition source) ─────────────────────────────────────────────────
+
+ @Test
+ public void showPartitionsPathListsFresh() {
+ // listPartitionNames backs SHOW PARTITIONS + the partitions() TVF -> must bypass the cache.
+ // MUTATION: routing it through the cached listPartitionNames -> freshCalls==0 -> red.
+ FakeHmsClient hms = new FakeHmsClient(ONE_PARTITION);
+ HudiConnectorMetadata md = hiveSyncMetadata(hms);
+ md.listPartitionNames(null, partitioned());
+ Assertions.assertEquals(1, hms.freshCalls, "SHOW PARTITIONS must list FRESH (bypass cache)");
+ Assertions.assertEquals(0, hms.cachedCalls, "SHOW PARTITIONS must NOT read the cached listing");
+ }
+
+ @Test
+ public void partitionValuesTvfListsFresh() {
+ // partition_values() TVF is user-facing enumeration too -> fresh, like listPartitionNames.
+ FakeHmsClient hms = new FakeHmsClient(ONE_PARTITION);
+ HudiConnectorMetadata md = hiveSyncMetadata(hms);
+ md.listPartitionValues(null, partitioned(), YEAR_MONTH);
+ Assertions.assertEquals(1, hms.freshCalls, "partition_values() must list FRESH (bypass cache)");
+ Assertions.assertEquals(0, hms.cachedCalls, "partition_values() must NOT read the cached listing");
+ }
+
+ @Test
+ public void queryPruningPathUsesCache() {
+ // listPartitions backs query pruning / MTMV -> must use the cache (the wrap's intended win).
+ // MUTATION: routing it through listPartitionNamesFresh -> cachedCalls==0 -> red.
+ FakeHmsClient hms = new FakeHmsClient(ONE_PARTITION);
+ HudiConnectorMetadata md = hiveSyncMetadata(hms);
+ md.listPartitions(null, partitioned(), java.util.Optional.empty());
+ Assertions.assertEquals(1, hms.cachedCalls, "query pruning must read the CACHED listing");
+ Assertions.assertEquals(0, hms.freshCalls, "query pruning must NOT force a fresh listing");
+ }
+
+ // ── REFRESH flush wiring ───────────────────────────────────────────────────────────────────────────────
+
+ @Test
+ public void invalidateTableFlushesCache() {
+ FakeHmsClient delegate = new FakeHmsClient(ONE_PARTITION);
+ CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap());
+ // Populate the (db,t) partition-name cache: two reads = ONE delegate hit (cached).
+ cache.listPartitionNames("db", "t", -1);
+ cache.listPartitionNames("db", "t", -1);
+ Assertions.assertEquals(1, delegate.cachedCalls, "second read must be served from cache");
+
+ // REFRESH TABLE -> the connector must flush this table from the cache (MUTATION: an empty override -> the
+ // next read stays cached -> cachedCalls==1 -> red).
+ connector().invalidateTable(cache, "db", "t");
+ cache.listPartitionNames("db", "t", -1);
+ Assertions.assertEquals(2, delegate.cachedCalls, "after REFRESH TABLE the next read must miss the cache");
+ }
+
+ @Test
+ public void invalidateDbFlushesCache() {
+ FakeHmsClient delegate = new FakeHmsClient(ONE_PARTITION);
+ CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap());
+ cache.listPartitionNames("db", "t", -1);
+ cache.listPartitionNames("db", "t", -1);
+ Assertions.assertEquals(1, delegate.cachedCalls);
+
+ connector().invalidateDb(cache, "db");
+ cache.listPartitionNames("db", "t", -1);
+ Assertions.assertEquals(2, delegate.cachedCalls, "after REFRESH DATABASE the next read must miss the cache");
+ }
+
+ @Test
+ public void invalidateAllFlushesCache() {
+ FakeHmsClient delegate = new FakeHmsClient(ONE_PARTITION);
+ CachingHmsClient cache = new CachingHmsClient(delegate, Collections.emptyMap());
+ cache.listPartitionNames("db", "t", -1);
+ cache.listPartitionNames("db", "t", -1);
+ Assertions.assertEquals(1, delegate.cachedCalls);
+
+ connector().invalidateAll(cache);
+ cache.listPartitionNames("db", "t", -1);
+ Assertions.assertEquals(2, delegate.cachedCalls, "after REFRESH CATALOG the next read must miss the cache");
+ }
+
+ @Test
+ public void invalidateOnUnbuiltClientIsNoOp() {
+ // REFRESH on a never-queried catalog must not force-build a client (nothing to flush). The public
+ // overrides read the null hmsClient field; they must not throw.
+ Assertions.assertDoesNotThrow(() -> {
+ connector().invalidateTable("db", "t");
+ connector().invalidateDb("db");
+ connector().invalidateAll();
+ });
+ }
+
+ // ── helpers ────────────────────────────────────────────────────────────────────────────────────────────
+
+ private static HudiConnector connector() {
+ return new HudiConnector(Collections.emptyMap(), new ConnectorContext() {
+ @Override
+ public String getCatalogName() {
+ return "test_catalog";
+ }
+
+ @Override
+ public long getCatalogId() {
+ return 1L;
+ }
+ });
+ }
+
+ private static HudiTableHandle partitioned() {
+ return new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE")
+ .partitionKeyNames(YEAR_MONTH).build();
+ }
+
+ private static HudiConnectorMetadata hiveSyncMetadata(HmsClient hms) {
+ // hive-sync so collectPartitions lists partition names from HMS (where the fresh/cached split lives);
+ // the stub executor returns the canned instant latestInstant would read off the timeline.
+ return new HudiConnectorMetadata(hms,
+ Collections.singletonMap("use_hive_sync_partition", "true"), stub(7L));
+ }
+
+ /** Executor that ignores the action and returns a canned value (stubs out the live metaClient). */
+ private static HudiMetaClientExecutor stub(Object cannedReturn) {
+ return new HudiMetaClientExecutor() {
+ @Override
+ @SuppressWarnings("unchecked")
+ public T execute(Callable action) {
+ return (T) cannedReturn;
+ }
+ };
+ }
+
+ /**
+ * {@link HmsClient} double that counts the CACHED {@link #listPartitionNames} vs the FRESH
+ * {@link #listPartitionNamesFresh} separately, so a test can pin which freshness contract each entry point
+ * selects. Everything else fails loud.
+ */
+ private static final class FakeHmsClient implements HmsClient {
+ int cachedCalls;
+ int freshCalls;
+ private final List names;
+
+ FakeHmsClient(List names) {
+ this.names = names;
+ }
+
+ @Override
+ public List listPartitionNames(String dbName, String tableName, int maxParts) {
+ cachedCalls++;
+ return names;
+ }
+
+ @Override
+ public List listPartitionNamesFresh(String dbName, String tableName, int maxParts) {
+ freshCalls++;
+ return names;
+ }
+
+ @Override
+ public List listDatabases() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public HmsDatabaseInfo getDatabase(String dbName) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List listTables(String dbName) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean tableExists(String dbName, String tableName) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public HmsTableInfo getTable(String dbName, String tableName) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Map getDefaultColumnValues(String dbName, String tableName) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List getPartitions(String dbName, String tableName, List partNames) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public HmsPartitionInfo getPartition(String dbName, String tableName, List values) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorOwnsHandleTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorOwnsHandleTest.java
index f86b5bc0b28c74..e1f883f5a35be0 100644
--- a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorOwnsHandleTest.java
+++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorOwnsHandleTest.java
@@ -31,8 +31,8 @@
* concrete handle type is invisible across the plugin classloader split.
*
* ownsHandle must be TRUE for this connector's own {@link HudiTableHandle} and FALSE for any other connector's
- * handle (e.g. an iceberg sibling's), so the gateway routes correctly. Dormant until hms enters
- * {@code SPI_READY_TYPES}: no production path asks this connector yet, so this is a Rule-9 routing guard.
+ * handle (e.g. an iceberg sibling's), so the gateway routes correctly. With hms live, the flipped gateway routes
+ * real hudi handles here, so this is a Rule-9 routing guard.
*/
public class HudiConnectorOwnsHandleTest {
diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java
index a3b6d1753befda..c206e30a70482e 100644
--- a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java
+++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java
@@ -34,8 +34,8 @@
* future change cannot silently make a hudi table writable or DDL-mutable, and a hudi write is never
* mis-executed as an iceberg write.
*
- *
Dormant until hms enters {@code SPI_READY_TYPES} and hudi handles are diverted (no production path reaches
- * this connector yet); this is a Rule-9 behavior lock. The correctness at flip is asserted end-to-end (a
+ *
With hms live and hudi handles diverted, this connector answers those calls on the production path;
+ * this is a Rule-9 behavior lock. The correctness at flip is asserted end-to-end (a
* heterogeneous HMS catalog where hudi INSERT/DELETE/MERGE/EXECUTE all fail loud read-only).
*/
public class HudiReadOnlyWriteRejectTest {
diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiStatementMemoTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiStatementMemoTest.java
new file mode 100644
index 00000000000000..91e9d0c45cb5b2
--- /dev/null
+++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiStatementMemoTest.java
@@ -0,0 +1,227 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.hudi;
+
+import org.apache.doris.connector.api.ConnectorColumn;
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScope;
+import org.apache.doris.connector.api.ConnectorType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+/**
+ * Guards the per-statement metaClient memos: hudi keeps TWO INDEPENDENT memos, one for the latest columns
+ * (behind {@code getTableSchema} with no time-travel pin) and one for the latest completed instant (behind
+ * {@code beginQuerySnapshot}). Within one statement each fact is read at most once; the two never couple. Each
+ * assertion pins WHY it matters:
+ *
+ * - repeated latest {@code getTableSchema} reads in one statement collapse to a single
+ * {@code getSchemaFromMetaClient} — a mutation dropping the schema memo makes it run twice and turns this red;
+ * - repeated {@code beginQuerySnapshot} pins collapse to a single {@code latestInstant}, likewise;
+ * - the two memos are INDEPENDENT — a schema read triggers NO instant read and vice versa (a mutation that
+ * coupled them, e.g. a shared loader, would make the cross-counter non-zero);
+ * - under {@link ConnectorStatementScope#NONE} and a {@code null} session each fact loads every time —
+ * byte-identical to resolving it per call, as before the memo (no cross-statement caching).
+ *
+ *
+ * The two loaders are overridden with canned results (no live metaClient / plugin auth), so this unit locks the
+ * sharing + independence wiring; each loader's byte-parity with the real read is guaranteed by reusing the exact
+ * connector method ({@code getSchemaFromMetaClient} / {@code latestInstant}) as the loader.
+ */
+public class HudiStatementMemoTest {
+
+ private static final long INSTANT = 20240101120000000L;
+
+ /** Records how many times each latest-fact loader actually ran (a memo miss). */
+ private static final class RecordingMetadata extends HudiConnectorMetadata {
+ int schemaLoads;
+ int instantLoads;
+
+ RecordingMetadata() {
+ super(null, Collections.emptyMap(), null);
+ }
+
+ @Override
+ List getSchemaFromMetaClient(String basePath, String queryInstant) {
+ this.schemaLoads++;
+ return Collections.singletonList(
+ new ConnectorColumn("c", ConnectorType.of("INT"), "", true, null));
+ }
+
+ @Override
+ long latestInstant(HudiTableHandle handle) {
+ this.instantLoads++;
+ return INSTANT;
+ }
+ }
+
+ private static HudiTableHandle handle() {
+ return new HudiTableHandle.Builder("db", "t", "s3://b/t", "COPY_ON_WRITE").build();
+ }
+
+ @Test
+ public void repeatedLatestSchemaReadsShareOneLoadWithinStatement() {
+ RecordingMetadata m = new RecordingMetadata();
+ MemoSession session = new MemoSession(7L, "q1", new MemoScope());
+ m.getTableSchema(session, handle());
+ m.getTableSchema(session, handle());
+ Assertions.assertEquals(1, m.schemaLoads, "repeated latest getTableSchema must share one metaClient read");
+ Assertions.assertEquals(0, m.instantLoads, "a schema read must NOT trigger an instant read (memos independent)");
+ }
+
+ @Test
+ public void repeatedSnapshotPinsShareOneLoadWithinStatement() {
+ RecordingMetadata m = new RecordingMetadata();
+ MemoSession session = new MemoSession(7L, "q1", new MemoScope());
+ long a = m.beginQuerySnapshot(session, handle()).get().getSnapshotId();
+ long b = m.beginQuerySnapshot(session, handle()).get().getSnapshotId();
+ Assertions.assertEquals(INSTANT, a);
+ Assertions.assertEquals(INSTANT, b);
+ Assertions.assertEquals(1, m.instantLoads, "repeated beginQuerySnapshot must share one instant read");
+ Assertions.assertEquals(0, m.schemaLoads, "an instant read must NOT trigger a schema read (memos independent)");
+ }
+
+ @Test
+ public void noneScopeLoadsEachTime() {
+ // A live session whose scope is NONE (no per-statement context) must NOT share — load every time.
+ RecordingMetadata m = new RecordingMetadata();
+ MemoSession session = new MemoSession(7L, "q1", ConnectorStatementScope.NONE);
+ m.getTableSchema(session, handle());
+ m.getTableSchema(session, handle());
+ m.beginQuerySnapshot(session, handle());
+ m.beginQuerySnapshot(session, handle());
+ Assertions.assertEquals(2, m.schemaLoads, "NONE scope -> schema loads every time");
+ Assertions.assertEquals(2, m.instantLoads, "NONE scope -> instant loads every time");
+ }
+
+ @Test
+ public void nullSessionLoadsEachTime() {
+ // Offline / direct-construction (null session): each resolve loads, byte-identical to loading every time.
+ RecordingMetadata m = new RecordingMetadata();
+ m.getTableSchema(null, handle());
+ m.getTableSchema(null, handle());
+ m.beginQuerySnapshot(null, handle());
+ m.beginQuerySnapshot(null, handle());
+ Assertions.assertEquals(2, m.schemaLoads, "null session -> schema loads every time");
+ Assertions.assertEquals(2, m.instantLoads, "null session -> instant loads every time");
+ }
+
+ @Test
+ public void allNamespacesArePrefixedWithConnectorType() throws Exception {
+ // NORM (self-extending): reflect over every "*_NAMESPACE" constant this connector declares and assert each
+ // is prefixed with the connector's ConnectorProvider.getType() ("hudi."). Source-prefixing keeps the value
+ // types (columns vs instant) — and any other connector's — distinct on the shared coordinate. Reflecting
+ // means a NEW namespace is auto-covered; a forgotten prefix or a getType() drift turns this red.
+ String prefix = new HudiConnectorProvider().getType() + ".";
+ int checked = 0;
+ for (Field f : HudiStatementScope.class.getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers()) && f.getType() == String.class
+ && f.getName().endsWith("_NAMESPACE")) {
+ f.setAccessible(true);
+ String ns = (String) f.get(null);
+ Assertions.assertTrue(ns.startsWith(prefix),
+ f.getName() + " (\"" + ns + "\") must be prefixed with the connector type \"" + prefix + "\"");
+ checked++;
+ }
+ }
+ Assertions.assertTrue(checked > 0, "expected at least one *_NAMESPACE constant to guard");
+ }
+
+ /** A statement scope that memoizes like the engine's real one (ConcurrentHashMap computeIfAbsent). */
+ private static final class MemoScope implements ConnectorStatementScope {
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T computeIfAbsent(String key, Supplier loader) {
+ return (T) cache.computeIfAbsent(key, k -> loader.get());
+ }
+ }
+
+ /** Minimal {@link ConnectorSession} carrying a catalog id, queryId and scope for the per-statement memo. */
+ private static final class MemoSession implements ConnectorSession {
+ private final long catalogId;
+ private final String queryId;
+ private final ConnectorStatementScope scope;
+
+ MemoSession(long catalogId, String queryId, ConnectorStatementScope scope) {
+ this.catalogId = catalogId;
+ this.queryId = queryId;
+ this.scope = scope;
+ }
+
+ @Override
+ public long getCatalogId() {
+ return catalogId;
+ }
+
+ @Override
+ public String getQueryId() {
+ return queryId;
+ }
+
+ @Override
+ public String getSessionId() {
+ return "session-" + queryId;
+ }
+
+ @Override
+ public ConnectorStatementScope getStatementScope() {
+ return scope;
+ }
+
+ @Override
+ public String getUser() {
+ return "u";
+ }
+
+ @Override
+ public String getTimeZone() {
+ return "UTC";
+ }
+
+ @Override
+ public String getLocale() {
+ return "en_US";
+ }
+
+ @Override
+ public String getCatalogName() {
+ return "c";
+ }
+
+ @Override
+ public T getProperty(String name, Class type) {
+ return null;
+ }
+
+ @Override
+ public Map getCatalogProperties() {
+ return Collections.emptyMap();
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
index f1d47d064f36b4..a7ab0f03aab137 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
@@ -434,7 +434,10 @@ public Optional loadNamespaceLocation(String dbName) {
@Override
public void addColumn(String dbName, String tableName, IcebergColumnChange column,
ConnectorColumnPosition position) {
- UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema();
+ Table table = loadTable(dbName, tableName);
+ IcebergNestedColumnEvolution.validateNoCaseInsensitiveSiblingCollision(
+ table.schema().asStruct(), "", column.getName(), null, "add");
+ UpdateSchema updateSchema = table.updateSchema();
updateSchema.addColumn(column.getName(), column.getType(), column.getComment(),
column.getDefaultValue());
applyPosition(updateSchema, position, column.getName());
@@ -443,7 +446,9 @@ public void addColumn(String dbName, String tableName, IcebergColumnChange colum
@Override
public void addColumns(String dbName, String tableName, List columns) {
- UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema();
+ Table table = loadTable(dbName, tableName);
+ IcebergNestedColumnEvolution.validateNoCaseInsensitiveTopLevelCollisions(table.schema(), columns);
+ UpdateSchema updateSchema = table.updateSchema();
for (IcebergColumnChange column : columns) {
updateSchema.addColumn(column.getName(), column.getType(), column.getComment(),
column.getDefaultValue());
@@ -460,9 +465,7 @@ public void dropColumn(String dbName, String tableName, String columnName) {
@Override
public void renameColumn(String dbName, String tableName, String oldName, String newName) {
- UpdateSchema updateSchema = loadTable(dbName, tableName).updateSchema();
- updateSchema.renameColumn(oldName, newName);
- updateSchema.commit();
+ IcebergNestedColumnEvolution.renameTopLevelColumn(loadTable(dbName, tableName), oldName, newName);
}
@Override
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java
index aa179082d3d234..6caf556f5780f6 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java
@@ -55,13 +55,10 @@ final class IcebergCommentCache {
private final MetaCacheEntry entry;
IcebergCommentCache(long ttlSeconds, int maxSize) {
- // Mirror IcebergTableCache: translate the connector's "<= 0 disables" contract to CacheSpec's ttl == 0
- // (disabled) rather than passing a negative value through, which CacheSpec reads as ttl == -1 "no
- // expiration (enabled)". This ttl<=0 -> disabled mapping is load-bearing: a vended no-cache catalog builds
- // this object but must NOT cache comments (operator "no meta cache" intent).
- CacheSpec spec = ttlSeconds > 0
- ? CacheSpec.of(true, ttlSeconds, maxSize)
- : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize);
+ // "<= 0 disables" connector TTL contract, folded to CacheSpec's disable sentinel (CacheSpec.ofConnectorTtl).
+ // Load-bearing here: a vended no-cache catalog builds this object but must NOT cache comments
+ // (operator "no meta cache" intent).
+ CacheSpec spec = CacheSpec.ofConnectorTtl(ttlSeconds, maxSize);
this.entry = new MetaCacheEntry<>("iceberg-comment", null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
index 350c767b92b61a..16f09143d38522 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
@@ -30,7 +30,7 @@
import org.apache.doris.connector.api.procedure.ConnectorProcedureOps;
import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider;
import org.apache.doris.connector.api.write.ConnectorWritePlanProvider;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
import org.apache.doris.connector.metastore.HmsMetaStoreProperties;
import org.apache.doris.connector.metastore.spi.JdbcDriverSupport;
import org.apache.doris.connector.metastore.spi.MetaStoreProviders;
@@ -162,30 +162,33 @@ public class IcebergConnector implements Connector {
// connector (PluginDrivenExternalCatalog.onClose nulls + recreates it) and thus drops both caches. The
// manifest cache is path-keyed, no-TTL, capacity-bounded; it is consumed only when
// meta.cache.iceberg.manifest.enable is set (default off → scan uses the SDK planFiles path).
- // Each cross-query cache below carries an isolation-discipline marker enforced by
- // tools/check-authz-cache-sharding.sh: under iceberg.rest.session=user a shared, un-partitioned cache would
- // bypass the per-user loadTable authorization (a metadata disclosure), so every cache field must declare
- // either 'authz-cache-session-user-disabled' (null under isUserSessionEnabled()) or 'authz-cache-exempt'.
- private final IcebergLatestSnapshotCache latestSnapshotCache; // authz-cache-session-user-disabled
+ // ATTN — authorization-sensitive cache isolation (a REVIEWED invariant; no build gate enforces it).
+ // Under iceberg.rest.session=user the per-user authorization lives INSIDE the delegated loadTable, so a
+ // shared, un-partitioned cross-query cache that returns metadata WITHOUT that per-user load is a
+ // "list != load" disclosure. Every cross-query cache below is therefore nulled under isUserSessionEnabled()
+ // (see the constructor's `? null : new ...` ternaries) except manifestCache (exempt: read only after a
+ // per-user resolveTable). If you ADD a cross-query cache here or in IcebergConnectorMetadata, it MUST be
+ // null under session=user and covered by IcebergConnectorCacheTest (which asserts exactly that at runtime).
+ private final IcebergLatestSnapshotCache latestSnapshotCache; // null under session=user
// PERF-01: cross-query cache of the RAW iceberg Table (restores the legacy IcebergExternalMetaCache table
// cache that the SPI cutover dropped). null when the catalog's credentials are query-dependent
// (iceberg.rest.session=user / REST vended-credentials) — see the constructor. The per-statement scope
// (ConnectorStatementScope) shares one loaded table across a statement's read/scan/write regardless of this
// field, and is what a credential-gated catalog (this field null) relies on within a statement.
- private final IcebergTableCache tableCache; // authz-cache-session-user-disabled
+ private final IcebergTableCache tableCache; // null under session=user
// PERF-02: cross-query partition-view cache (the raw PARTITIONS-scan result, keyed by (table, snapshotId)).
// The value is pure metadata (no FileIO/credential), but under session=user it is an authorization-sensitive
// projection (a shared hit would disclose one user's partitions), so it is disabled there (see constructor).
- private final IcebergPartitionCache partitionCache; // authz-cache-session-user-disabled
+ private final IcebergPartitionCache partitionCache; // null under session=user
// PERF-03: cross-query inferred-file-format cache (the whole-table planFiles() fallback result, keyed by
// (table, snapshotId)). Same authorization-sensitive treatment as partitionCache: disabled under session=user.
- private final IcebergFormatCache formatCache; // authz-cache-session-user-disabled
+ private final IcebergFormatCache formatCache; // null under session=user
// PERF-05: cross-query table-comment cache (value = the 'comment' property string). Built ONLY for a REST
// vended-credentials catalog that is NOT session=user -- plain catalogs already reuse tableCache (PERF-01) for
// the comment path, and session=user must stay live because the loadTable itself carries per-user
// authorization a shared cache would bypass. null for every other flavor.
- private final IcebergCommentCache commentCache; // authz-cache-session-user-disabled
- // PERF-06: cross-query DERIVED partition-view cache ("cache A", the generic ConnectorPartitionViewCache from
+ private final IcebergCommentCache commentCache; // null under session=user
+ // PERF-06: cross-query DERIVED partition-view cache ("cache A", the generic ConnectorMetadataCache from
// fe-connector-cache), layered ABOVE the raw partitionCache (PERF-02): it memoizes the BUILT derived view
// (transform-to-range math + overlap merge for the MTMV view; the value-map construction for listPartitions)
// keyed by (db, table, snapshotId, schemaId), so a repeated query on a partitioned table skips the derived
@@ -194,12 +197,12 @@ public class IcebergConnector implements Connector {
// deduped by partitionCache, so two derived caches never double-scan remotely. Authorization-sensitive
// projection like partitionCache/formatCache: disabled (null) under iceberg.rest.session=user so a shared
// (no user dimension) hit cannot disclose one user's partition view; kept for every other flavor.
- private final ConnectorPartitionViewCache // authz-cache-session-user-disabled
+ private final ConnectorMetadataCache // null under session=user
mvccPartitionViewCache;
- private final ConnectorPartitionViewCache> // authz-cache-session-user-disabled
+ private final ConnectorMetadataCache> // null under session=user
listPartitionsViewCache;
// Manifest content cache — pure metadata, default-off (meta.cache.iceberg.manifest.enable), and consumed
- // ONLY after a per-user resolveTable(ForRead). authz-cache-exempt (no read path without a per-user load).
+ // ONLY after a per-user resolveTable(ForRead) -- exempt: no read path without a per-user load.
private final IcebergManifestCache manifestCache = new IcebergManifestCache();
// Lazily-built plugin-side Kerberos authenticator (single-owner auth; see TcclPinningConnectorContext).
@@ -271,17 +274,17 @@ public IcebergConnector(Map properties, ConnectorContext context
? new IcebergCommentCache(
resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY)
: null;
- // PERF-06: derived partition-view cache A (generic ConnectorPartitionViewCache). Same
+ // PERF-06: derived partition-view cache A (generic ConnectorMetadataCache). Same
// authorization-sensitive treatment as partitionCache -- disabled (null) under iceberg.rest.session=user so
// a shared (no user dimension) hit cannot disclose one user's partition view; kept otherwise. Reads its
// own meta.cache.iceberg.partition_view.(enable|ttl-second|capacity) from the catalog properties via the
// framework's CacheSpec (default ON / 24h / 1000). Two typed instances (MVCC view + partition-info list).
this.mvccPartitionViewCache = isUserSessionEnabled()
? null
- : new ConnectorPartitionViewCache<>("iceberg", this.properties);
+ : new ConnectorMetadataCache<>("iceberg", "partition_view", this.properties);
this.listPartitionsViewCache = isUserSessionEnabled()
? null
- : new ConnectorPartitionViewCache<>("iceberg", this.properties);
+ : new ConnectorMetadataCache<>("iceberg", "partition_view", this.properties);
}
/**
@@ -761,12 +764,12 @@ IcebergCommentCache commentCacheForTest() {
}
/** Test-only: the derived MVCC partition-view cache (PERF-06), or {@code null} for a session=user catalog. */
- ConnectorPartitionViewCache mvccPartitionViewCacheForTest() {
+ ConnectorMetadataCache mvccPartitionViewCacheForTest() {
return mvccPartitionViewCache;
}
/** Test-only: the derived listPartitions view cache (PERF-06), or {@code null} for a session=user catalog. */
- ConnectorPartitionViewCache> listPartitionsViewCacheForTest() {
+ ConnectorMetadataCache> listPartitionsViewCacheForTest() {
return listPartitionsViewCache;
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
index f771a8531d68cd..158c1fbbb188b9 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadata.java
@@ -43,8 +43,8 @@
import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot;
import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec;
import org.apache.doris.connector.api.pushdown.ConnectorExpression;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
-import org.apache.doris.connector.cache.PartitionViewCacheKey;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
+import org.apache.doris.connector.cache.ConnectorTableKey;
import org.apache.doris.connector.spi.ConnectorContext;
import org.apache.doris.thrift.THiveTable;
import org.apache.doris.thrift.TIcebergTable;
@@ -161,14 +161,14 @@ public class IcebergConnectorMetadata implements ConnectorMetadata {
// connector is a REST vended-credentials, non-session catalog (see IcebergConnector); the convenience ctors
// used by direct-construction tests pass null. Consumed only by getTableComment.
private final IcebergCommentCache commentCache;
- // PERF-06: cross-query DERIVED partition-view cache A (generic ConnectorPartitionViewCache), injected by the
+ // PERF-06: cross-query DERIVED partition-view cache A (generic ConnectorMetadataCache), injected by the
// owning IcebergConnector; null = no cross-query derived layer (the convenience ctors used by
// direct-construction tests pass null; a session=user catalog also passes null). Layered ABOVE partitionCache
// (raw rows): a hit skips the derived-view BUILD, keyed by (db, table, snapshotId, schemaId). Two typed fields
// because getMvccPartitionView and listPartitions return different derived types. Consumed by
// getMvccPartitionView / listPartitions respectively.
- private final ConnectorPartitionViewCache mvccPartitionViewCache;
- private final ConnectorPartitionViewCache> listPartitionsViewCache;
+ private final ConnectorMetadataCache mvccPartitionViewCache;
+ private final ConnectorMetadataCache> listPartitionsViewCache;
public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties,
ConnectorContext context) {
@@ -214,8 +214,8 @@ public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map mvccPartitionViewCache,
- ConnectorPartitionViewCache> listPartitionsViewCache) {
+ ConnectorMetadataCache mvccPartitionViewCache,
+ ConnectorMetadataCache> listPartitionsViewCache) {
this.catalogOps = catalogOps;
this.properties = properties;
this.context = context;
@@ -1693,11 +1693,10 @@ private static boolean isSupportedSysTable(String sysName) {
* per-manager map and {@code GlobalExternalTransactionInfoMgr}) and stamped into the data sink —
* the BE→FE report path finds the txn by this id to feed it commit fragments.
*
- * Gate-closed / dormant until the P6.6 cutover: nothing routes plugin-driven iceberg writes
- * through this path yet. The single SDK {@code org.apache.iceberg.Transaction} that backs commit is
- * opened lazily by the write plan via {@link IcebergConnectorTransaction#beginWrite}; op selection
- * (T04), the commit-validation suite (T05), the sink (T06), and the {@code supportsInsert/Delete/
- * Merge} capability declarations (T06/T07) land in later tasks.
+ * Live since the iceberg SPI cutover: plugin-driven iceberg writes route through this path. The
+ * single SDK {@code org.apache.iceberg.Transaction} that backs commit is opened lazily by the write
+ * plan via {@link IcebergConnectorTransaction#beginWrite}; op selection, the commit-validation suite,
+ * the sink, and the {@code supportsInsert/Delete/Merge} capability declarations are all in place.
*/
@Override
public ConnectorTransaction beginTransaction(ConnectorSession session) {
@@ -1824,7 +1823,7 @@ public Optional getMvccPartitionView(
if (mvccPartitionViewCache == null) {
return Optional.of(buildMvccPartitionViewUncached(session, iceHandle));
}
- PartitionViewCacheKey key = new PartitionViewCacheKey(iceHandle.getDbName(),
+ ConnectorTableKey key = new ConnectorTableKey(iceHandle.getDbName(),
iceHandle.getTableName(), iceHandle.getSnapshotId(), iceHandle.getSchemaId());
return Optional.of(mvccPartitionViewCache.get(key,
() -> buildMvccPartitionViewUncached(session, iceHandle)));
@@ -1900,7 +1899,7 @@ public List listPartitions(ConnectorSession session,
if (listPartitionsViewCache == null || filter.isPresent()) {
return listPartitionsUncached(session, iceHandle);
}
- PartitionViewCacheKey key = new PartitionViewCacheKey(iceHandle.getDbName(),
+ ConnectorTableKey key = new ConnectorTableKey(iceHandle.getDbName(),
iceHandle.getTableName(), iceHandle.getSnapshotId(), iceHandle.getSchemaId());
return listPartitionsViewCache.get(key, () -> listPartitionsUncached(session, iceHandle));
});
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
index 10f3629601e1f3..58416097ec7213 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java
@@ -109,8 +109,8 @@
* O5-2 write constraint ({@link #applyWriteConstraint}, a neutral {@link ConnectorPredicate} converted lazily
* at commit) ANDed with a commit-time identity-partition filter derived from the commit fragments.
*
- * Gate-closed / dormant. Iceberg is not in {@code SPI_READY_TYPES} until the P6.6 cutover, so
- * nothing routes plugin-driven iceberg writes through this class yet ({@link #beginWrite} is wired by T06's
+ *
Live since the iceberg SPI cutover. {@code iceberg} is in {@code SPI_READY_TYPES}, so
+ * plugin-driven iceberg writes route through this class ({@link #beginWrite} is wired by
* {@code planWrite}). The txn-id is the engine-allocated Doris global id, so the generic
* {@code PluginDrivenTransactionManager} registers it in both the per-manager map and
* {@code GlobalExternalTransactionInfoMgr} — no per-connector registration code is needed, mirroring
@@ -154,7 +154,7 @@ public class IcebergConnectorTransaction implements ConnectorTransaction, Rewrit
// Session zone for human-readable TIMESTAMP partition value parsing (DV-T04-f).
private ZoneId zone = ZoneOffset.UTC;
- // ── REWRITE (rewrite_data_files, P6.4-T06; dormant until the P6.6 cutover) ──
+ // ── REWRITE (rewrite_data_files; live since the iceberg SPI cutover) ──
// The original data files to remove, fed by updateRewriteFiles (the rewrite execution half hands it the
// planner's RewriteDataGroup.getDataFiles() — FileScanTask.file()). The new compacted files arrive on the
// shared commitDataList channel (like INSERT) and are materialized into filesToAdd at commit time.
@@ -346,7 +346,7 @@ public void applyWriteConstraint(ConnectorPredicate targetOnlyFilter) {
* REWRITE: registers original data files to remove (the rewrite execution half feeds it one bin-packed
* group at a time — {@code RewriteDataGroup.getDataFiles()}). Accumulates across calls. Ported from legacy
* {@code IcebergTransaction.updateRewriteFiles}; package-visible because the rewrite coordinator lives in
- * the connector (fe-core cannot traffic in iceberg {@code DataFile}). Dormant until the P6.6 cutover.
+ * the connector (fe-core cannot traffic in iceberg {@code DataFile}). Live since the iceberg SPI cutover.
*/
void updateRewriteFiles(List originalFiles) {
synchronized (filesToDelete) {
@@ -999,8 +999,8 @@ private static int formatVersion(Table table) {
* Unlike legacy {@code IcebergTransaction.collectRewrittenDeleteFiles} (which looked the old files up in a
* scan-time map fed from the read plan), this reads them from the write-time {@link #baseSnapshotId} the
* RowDelta validates against, so the removed deletes are snapshot-consistent with the commit by construction
- * (no read-vs-write snapshot skew). Post-flip deviation DV-S2-rederive (dormant: iceberg is not yet a
- * plugin-driven type, so this commit path runs only after the C5 flip).
+ * (no read-vs-write snapshot skew). Post-flip deviation DV-S2-rederive: iceberg is a plugin-driven type
+ * since the SPI flip, so this commit path is live.
*/
List collectRewrittenDeleteFiles(List deleteCommitData) {
if (deleteCommitData == null || deleteCommitData.isEmpty() || baseSnapshotId == null || table == null) {
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java
index 294b29b3738e8c..cb07b3e57da124 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java
@@ -91,11 +91,8 @@ public int hashCode() {
private final MetaCacheEntry entry;
IcebergFormatCache(long ttlSeconds, int maxSize) {
- // Mirror IcebergPartitionCache: translate the connector's "<= 0 disables" contract to CacheSpec's ttl == 0
- // (disabled) rather than passing a negative value through (which CacheSpec reads as "no expiration").
- CacheSpec spec = ttlSeconds > 0
- ? CacheSpec.of(true, ttlSeconds, maxSize)
- : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize);
+ // "<= 0 disables" connector TTL contract, folded to CacheSpec's disable sentinel (CacheSpec.ofConnectorTtl).
+ CacheSpec spec = CacheSpec.ofConnectorTtl(ttlSeconds, maxSize);
this.entry = new MetaCacheEntry<>("iceberg-format", null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java
index b953e178fbe377..35b63c6aa8c8fc 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergLatestSnapshotCache.java
@@ -67,13 +67,8 @@ static final class CachedSnapshot {
private final MetaCacheEntry entry;
IcebergLatestSnapshotCache(long ttlSeconds, int maxSize) {
- // ttl-second <= 0 disables caching (always read live); a positive ttl is access-based expiry with the
- // given capacity. CacheSpec treats ttl == -1 as "no expiration (enabled)" and ttl == 0 as "disabled",
- // so translate the connector's "<= 0 disables" contract to ttl == 0 rather than passing a negative
- // value straight through (which would otherwise flip -1 into a never-expiring cache).
- CacheSpec spec = ttlSeconds > 0
- ? CacheSpec.of(true, ttlSeconds, maxSize)
- : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize);
+ // "<= 0 disables" connector TTL contract, folded to CacheSpec's disable sentinel (CacheSpec.ofConnectorTtl).
+ CacheSpec spec = CacheSpec.ofConnectorTtl(ttlSeconds, maxSize);
this.entry = new MetaCacheEntry<>("iceberg-latest-snapshot", null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java
index f30eab3a8f0718..14facfd6ddc12f 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java
@@ -49,6 +49,11 @@
*
* No partial commit: every {@code UpdateSchema.commit()} is the final statement of each entry point, so
* any validation/resolution throw aborts the whole change before anything is committed (legacy parity).
+ *
+ * The TOP-LEVEL (flat) column ops stay on {@link IcebergCatalogOps}, but they share this class's name
+ * resolution and validators — iceberg matches field names case-SENSITIVELY while Doris column names are
+ * case-insensitive, so both arms must agree. See {@link #validateNoCaseInsensitiveSiblingCollision},
+ * {@link #validateNoCaseInsensitiveTopLevelCollisions} and {@link #renameTopLevelColumn}.
*/
public final class IcebergNestedColumnEvolution {
@@ -277,7 +282,45 @@ private static ConnectorColumnPath childPath(ConnectorColumnPath parentPath, Str
return ConnectorColumnPath.of(parts);
}
- private static void validateNoCaseInsensitiveSiblingCollision(Types.StructType parentType, String parentPath,
+ /**
+ * Renames the TOP-LEVEL column {@code oldName} to {@code newName}. {@code oldName} is resolved
+ * case-insensitively (iceberg's own {@code renameColumn} is case-sensitive and would report the column as
+ * missing when the user types a different case than the iceberg field), then {@code newName} is checked
+ * against the other top-level fields. Mirrors the legacy flat {@code IcebergMetadataOps.renameColumn}.
+ */
+ static void renameTopLevelColumn(Table table, String oldName, String newName) {
+ Schema schema = table.schema();
+ ResolvedColumnPath oldPath = resolveColumnPath(schema, ConnectorColumnPath.of(oldName), "rename");
+ validateNoCaseInsensitiveSiblingCollision(schema.asStruct(), "", newName, oldPath.getField(), "rename");
+
+ UpdateSchema updateSchema = table.updateSchema();
+ applyRenameColumn(schema, updateSchema, oldPath, newName);
+ updateSchema.commit();
+ }
+
+ /**
+ * Rejects a batch of TOP-LEVEL columns that collides case-insensitively with an existing field or with
+ * another column of the same request. Validates the whole batch before the caller stages anything, so a
+ * partly-valid request commits nothing. Mirrors the legacy
+ * {@code IcebergMetadataOps.validateNoCaseInsensitiveTopLevelCollisions}.
+ */
+ static void validateNoCaseInsensitiveTopLevelCollisions(Schema schema, List columns) {
+ Set requestedNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ for (IcebergColumnChange column : columns) {
+ validateNoCaseInsensitiveSiblingCollision(schema.asStruct(), "", column.getName(), null, "add");
+ if (!requestedNames.add(column.getName())) {
+ throw new DorisConnectorException("Cannot add column '" + column.getName()
+ + "': conflicts with another requested column (case-insensitive)");
+ }
+ }
+ }
+
+ /**
+ * Rejects {@code targetName} when it case-insensitively collides with a sibling of {@code parentType} that
+ * is not {@code sourceField} itself (the identity escape lets a pure re-casing rename through). An empty
+ * {@code parentPath} addresses the table's top-level columns.
+ */
+ static void validateNoCaseInsensitiveSiblingCollision(Types.StructType parentType, String parentPath,
String targetName, NestedField sourceField, String operation) {
NestedField conflictingField = parentType.caseInsensitiveField(targetName);
if (conflictingField != null
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java
index 3d68ab664cfb87..a85e2187043e7c 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java
@@ -88,12 +88,8 @@ public int hashCode() {
private final MetaCacheEntry> entry;
IcebergPartitionCache(long ttlSeconds, int maxSize) {
- // Mirror IcebergLatestSnapshotCache: translate the connector's "<= 0 disables" contract to CacheSpec's
- // ttl == 0 (disabled) rather than passing a negative value through (which CacheSpec reads as "no
- // expiration").
- CacheSpec spec = ttlSeconds > 0
- ? CacheSpec.of(true, ttlSeconds, maxSize)
- : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize);
+ // "<= 0 disables" connector TTL contract, folded to CacheSpec's disable sentinel (CacheSpec.ofConnectorTtl).
+ CacheSpec spec = CacheSpec.ofConnectorTtl(ttlSeconds, maxSize);
this.entry = new MetaCacheEntry<>("iceberg-partition", null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
index e3c4fb45ef6625..a1a67dd210c192 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java
@@ -796,7 +796,7 @@ private IcebergScanRange buildRangeForTask(FileScanTask task, Table table, int f
* pushdown does not apply (a metadata table has no snapshot-summary count). The serialized {@code
* FileScanTask} bytes are consumed verbatim by BE's {@code IcebergSysTableJniScanner}
* ({@code deserializeFromBase64(...).asDataTask().rows()}); FE unit tests cannot reach the BE classloader, so
- * the cross-version byte compatibility is covered by the P6.8 docker e2e. Dormant until P6.6.
+ * the cross-version byte compatibility is covered by the P6.8 docker e2e. Live since the iceberg SPI cutover.
*/
private List planSystemTableScan(IcebergTableHandle handle,
List columns, Optional filter,
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
index 28b0e28fe41d49..b4c6ee0929dd03 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
@@ -19,6 +19,7 @@
import org.apache.doris.connector.api.ConnectorSession;
import org.apache.doris.connector.api.ConnectorStatementScope;
+import org.apache.doris.connector.api.ConnectorStatementScopes;
import org.apache.doris.thrift.TIcebergDeleteFileDesc;
import org.apache.iceberg.Table;
@@ -47,6 +48,19 @@
*/
final class IcebergStatementScope {
+ /**
+ * Namespace for iceberg's per-statement RAW {@link Table} memo. Source-prefixed with the connector type
+ * ("iceberg") so it stays distinct across a heterogeneous gateway; see {@link ConnectorStatementScopes}.
+ */
+ static final String TABLE_NAMESPACE = "iceberg.table";
+
+ /**
+ * Namespace for iceberg's per-statement rewritable-delete supply map (a per-statement singleton keyed by
+ * catalog id + queryId, with no db/table — it aggregates across all touched data files). Source-prefixed
+ * with the connector type ("iceberg").
+ */
+ static final String REWRITABLE_DELETE_SUPPLY_NAMESPACE = "iceberg.rewritable-delete-supply";
+
private IcebergStatementScope() {}
/**
@@ -57,13 +71,11 @@ private IcebergStatementScope() {}
* (the caller wraps this in {@code executeAuthenticated}).
*/
static Table sharedTable(ConnectorSession session, String dbName, String tableName, Supplier loader) {
- if (session == null) {
- // No session (offline / direct-construction tests): load every time, like ConnectorStatementScope.NONE.
- return loader.get();
- }
- String key = "iceberg.table:" + session.getCatalogId() + ":" + dbName + ":" + tableName
- + ":" + session.getQueryId();
- return session.getStatementScope().computeIfAbsent(key, loader);
+ // Delegates to the shared per-statement resolver. The TABLE_NAMESPACE ("iceberg.table") reproduces the
+ // historical "iceberg.table:" key prefix byte-for-byte, so the funnel keeps identical hits / misses / NONE
+ // fall-through (proved by IcebergStatementScopeTest#sharedTableKeyReproducesLegacyPrefixByteForByte).
+ return ConnectorStatementScopes.resolveInStatement(
+ session, TABLE_NAMESPACE, dbName, tableName, loader);
}
/**
@@ -78,7 +90,7 @@ static Map> rewritableDeleteSupply(Connecto
// No session: a throwaway map that does NOT bridge scan->write (same as NONE).
return new ConcurrentHashMap<>();
}
- String key = "iceberg.rewritable-delete-supply:" + session.getCatalogId() + ":" + session.getQueryId();
+ String key = REWRITABLE_DELETE_SUPPLY_NAMESPACE + ":" + session.getCatalogId() + ":" + session.getQueryId();
return session.getStatementScope().computeIfAbsent(key, ConcurrentHashMap::new);
}
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
index 8a6b53ef599787..426b706cf11f90 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
@@ -61,12 +61,8 @@ final class IcebergTableCache {
private final MetaCacheEntry entry;
IcebergTableCache(long ttlSeconds, int maxSize) {
- // Mirror IcebergLatestSnapshotCache: translate the connector's "<= 0 disables" contract to CacheSpec's
- // ttl == 0 (disabled) rather than passing a negative value through, which CacheSpec reads as ttl == -1
- // "no expiration (enabled)".
- CacheSpec spec = ttlSeconds > 0
- ? CacheSpec.of(true, ttlSeconds, maxSize)
- : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize);
+ // "<= 0 disables" connector TTL contract, folded to CacheSpec's disable sentinel (CacheSpec.ofConnectorTtl).
+ CacheSpec spec = CacheSpec.ofConnectorTtl(ttlSeconds, maxSize);
this.entry = new MetaCacheEntry<>("iceberg-table", null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
index 8d346be5c85f74..efa0dfdd691ca0 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
@@ -95,8 +95,8 @@
* per-statement {@link IcebergStatementScope} carried across the scan→write seam (commit-bridge S4 part 2),
* replacing the legacy fe-resident rewritable-delete planner.
*
- * Gate-closed / dormant. Iceberg is not in {@code SPI_READY_TYPES} until P6.6, so nothing
- * routes iceberg writes through this provider yet; {@link #planWrite} requires the executor-bound
+ *
Live since the iceberg SPI cutover. {@code iceberg} is in {@code SPI_READY_TYPES}, so a
+ * plugin-driven iceberg write routes through this provider; {@link #planWrite} requires the executor-bound
* connector transaction and fails loud if absent.
*/
public class IcebergWritePlanProvider implements ConnectorWritePlanProvider {
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java
index c7fea971f86c7e..6a901feb349a74 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExecuteActionFactory.java
@@ -83,8 +83,8 @@ public static BaseIcebergAction createAction(String actionType, Map
+ * connector {@link RewriteDataFilePlanner}. Live since the iceberg SPI cutover.
*/
public class IcebergRewriteDataFilesAction extends BaseIcebergAction {
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
index 1e7eeffae30bf6..0b2bef024a9c42 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
@@ -131,6 +131,118 @@ public void testRenameColumn() {
Assertions.assertNotNull(reload().findField("full_name"));
}
+ // ---------- case-insensitive name collision on the flat ops (#65329 flat arm) ----------
+
+ /**
+ * Creates db1.mixed with Spark-style mixed-case names ({@code Id}, {@code Label},
+ * {@code Info STRUCT}) — the fixture of the regression suite
+ * {@code test_iceberg_nested_schema_evolution_spark_doris_interop}. Iceberg itself matches names
+ * case-SENSITIVELY, so without the connector guards a Doris user could add a second {@code id} next to
+ * {@code Id} and make the table unreadable through Doris (whose column names are case-insensitive).
+ */
+ private void createMixedCaseTable() {
+ Schema schema = new Schema(
+ Types.NestedField.optional(1, "Id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "Label", Types.StringType.get()),
+ Types.NestedField.optional(3, "Info", Types.StructType.of(
+ Types.NestedField.optional(4, "Metric", Types.IntegerType.get()))));
+ ops.createTable("db1", "mixed", schema, PartitionSpec.unpartitioned(), null,
+ IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap()));
+ }
+
+ @Test
+ public void testAddColumnCaseInsensitiveCollisionFailsLoud() {
+ createMixedCaseTable();
+ DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class,
+ () -> ops.addColumn("db1", "mixed", change("id", Types.StringType.get(), "", true), null));
+ Assertions.assertTrue(ex.getMessage()
+ .contains("conflicts with existing Iceberg field 'Id' (case-insensitive)"),
+ ex.getMessage());
+ Assertions.assertEquals(3, reload("mixed").columns().size());
+ }
+
+ @Test
+ public void testAddColumnsCaseInsensitiveCollisionFailsLoud() {
+ createMixedCaseTable();
+ DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class,
+ () -> ops.addColumns("db1", "mixed", Arrays.asList(
+ change("ok_col", Types.StringType.get(), "", true),
+ change("id", Types.StringType.get(), "", true))));
+ Assertions.assertTrue(ex.getMessage()
+ .contains("conflicts with existing Iceberg field 'Id' (case-insensitive)"),
+ ex.getMessage());
+ // Whole batch validated before anything is staged: the valid sibling must not be committed either.
+ Assertions.assertEquals(3, reload("mixed").columns().size());
+ }
+
+ @Test
+ public void testAddColumnsDuplicateRequestedNamesFailLoud() {
+ createMixedCaseTable();
+ DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class,
+ () -> ops.addColumns("db1", "mixed", Arrays.asList(
+ change("new_field", Types.StringType.get(), "", true),
+ change("NEW_FIELD", Types.StringType.get(), "", true))));
+ Assertions.assertTrue(ex.getMessage()
+ .contains("conflicts with another requested column (case-insensitive)"),
+ ex.getMessage());
+ Assertions.assertEquals(3, reload("mixed").columns().size());
+ }
+
+ @Test
+ public void testRenameColumnCaseInsensitiveCollisionFailsLoud() {
+ createMixedCaseTable();
+ DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class,
+ () -> ops.renameColumn("db1", "mixed", "label", "id"));
+ Assertions.assertTrue(ex.getMessage()
+ .contains("conflicts with existing Iceberg field 'Id' (case-insensitive)"),
+ ex.getMessage());
+ Assertions.assertNotNull(reload("mixed").findField("Label"));
+ }
+
+ @Test
+ public void testRenameColumnResolvesSourceNameCaseInsensitively() {
+ createMixedCaseTable();
+ // The user types the Doris-side (case-insensitive) name; it must resolve to the iceberg field 'Label'
+ // rather than hitting iceberg's case-sensitive "Cannot rename missing column".
+ ops.renameColumn("db1", "mixed", "label", "full_label");
+ Assertions.assertNull(reload("mixed").findField("Label"));
+ Assertions.assertNotNull(reload("mixed").findField("full_label"));
+ }
+
+ @Test
+ public void testRenameColumnCaseOnlySelfRenameSucceeds() {
+ createMixedCaseTable();
+ // 'Label' -> 'label' collides with itself; the field-identity escape in the guard must let it through,
+ // otherwise a pure re-casing of a column would be impossible.
+ ops.renameColumn("db1", "mixed", "label", "label");
+ Assertions.assertNotNull(reload("mixed").findField("label"));
+ Assertions.assertEquals(3, reload("mixed").columns().size());
+ }
+
+ @Test
+ public void testRenameColumnPreservesIdentifierFieldPath() {
+ // Iceberg drops an identifier-field path when the field is renamed; the flat rename must restate it
+ // (legacy IcebergMetadataOps.applyRenameColumn parity, already honoured by the nested arm).
+ Schema schema = new Schema(
+ Arrays.asList(
+ Types.NestedField.required(1, "Key", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "v", Types.StringType.get())),
+ Collections.singleton(1));
+ ops.createTable("db1", "ident", schema, PartitionSpec.unpartitioned(), null,
+ IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap()));
+ ops.renameColumn("db1", "ident", "key", "renamed_key");
+ Schema after = reload("ident");
+ Assertions.assertEquals(Collections.singleton(after.findField("renamed_key").fieldId()),
+ after.identifierFieldIds());
+ }
+
+ @Test
+ public void testAddColumnKeepsWorkingWhenNoCollision() {
+ createMixedCaseTable();
+ ops.addColumn("db1", "mixed", change("brand_new", Types.StringType.get(), "", true), null);
+ Assertions.assertNotNull(reload("mixed").findField("brand_new"));
+ }
+
// ---------- modifyColumn ----------
@Test
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java
index 5d5b5285f07d43..6abe09a0f52dfd 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java
@@ -18,8 +18,8 @@
package org.apache.doris.connector.iceberg;
import org.apache.doris.connector.api.ConnectorPartitionInfo;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
-import org.apache.doris.connector.cache.PartitionViewCacheKey;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
+import org.apache.doris.connector.cache.ConnectorTableKey;
import org.apache.iceberg.DataFiles;
import org.apache.iceberg.ManifestFile;
@@ -499,16 +499,16 @@ public void refreshHooksInvalidatePartitionViewCache() {
// re-run -> a loads assert below red.
IcebergConnector connector =
new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext());
- ConnectorPartitionViewCache> cache = connector.listPartitionsViewCacheForTest();
+ ConnectorMetadataCache> cache = connector.listPartitionsViewCacheForTest();
Assertions.assertNotNull(cache);
int[] loads = {0};
Supplier> loader = () -> {
loads[0]++;
return Collections.emptyList();
};
- PartitionViewCacheKey db1t1 = new PartitionViewCacheKey("db1", "t1", 1L, 1L);
- PartitionViewCacheKey db1t2 = new PartitionViewCacheKey("db1", "t2", 1L, 1L);
- PartitionViewCacheKey db2t1 = new PartitionViewCacheKey("db2", "t1", 1L, 1L);
+ ConnectorTableKey db1t1 = new ConnectorTableKey("db1", "t1", 1L, 1L);
+ ConnectorTableKey db1t2 = new ConnectorTableKey("db1", "t2", 1L, 1L);
+ ConnectorTableKey db2t1 = new ConnectorTableKey("db2", "t1", 1L, 1L);
// REFRESH TABLE db1.t1 -> only db1.t1 re-loads.
cache.get(db1t1, loader);
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
index 0ca659a7bd24f1..e5cb30b0ed97d8 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
@@ -21,7 +21,7 @@
import org.apache.doris.connector.api.mvcc.ConnectorMvccPartition;
import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView;
import org.apache.doris.connector.api.pushdown.ConnectorExpression;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
import org.apache.iceberg.DataFiles;
import org.apache.iceberg.FileFormat;
@@ -42,7 +42,7 @@
/**
* PERF-06 tests for the cross-query DERIVED partition-view cache ("cache A", the generic
- * {@link ConnectorPartitionViewCache}) wired into {@link IcebergConnectorMetadata#getMvccPartitionView} /
+ * {@link ConnectorMetadataCache}) wired into {@link IcebergConnectorMetadata#getMvccPartitionView} /
* {@link IcebergConnectorMetadata#listPartitions}. Uses the real {@link InMemoryCatalog} +
* {@link RecordingIcebergCatalogOps} harness (no Mockito, no docker): the cache sits ABOVE the per-query build,
* whose first step is {@code resolveTableForRead -> catalogOps.loadTable} (logged as {@code loadTable:db1.t1}), so
@@ -89,7 +89,7 @@ private static org.apache.iceberg.DataFile dayFile(PartitionSpec spec, String pa
}
private static IcebergConnectorMetadata metadataWithMvccCache(RecordingIcebergCatalogOps ops,
- ConnectorPartitionViewCache cache) {
+ ConnectorMetadataCache cache) {
// 9-arg ctor: disabled latest-snapshot cache + null table/partition/comment caches so the loadTable count
// reflects cache A alone; only the mvcc view cache under test is injected.
return new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext(),
@@ -97,7 +97,7 @@ private static IcebergConnectorMetadata metadataWithMvccCache(RecordingIcebergCa
}
private static IcebergConnectorMetadata metadataWithListCache(RecordingIcebergCatalogOps ops,
- ConnectorPartitionViewCache> cache) {
+ ConnectorMetadataCache> cache) {
return new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext(),
new IcebergLatestSnapshotCache(0L, 1), null, null, null, null, cache);
}
@@ -110,12 +110,12 @@ private static long loadCount(RecordingIcebergCatalogOps ops) {
return ops.log.stream().filter(s -> s.equals("loadTable:db1.t1")).count();
}
- private static ConnectorPartitionViewCache mvccCache() {
- return new ConnectorPartitionViewCache<>("iceberg", Collections.emptyMap());
+ private static ConnectorMetadataCache mvccCache() {
+ return new ConnectorMetadataCache<>("iceberg", "partition_view", Collections.emptyMap());
}
- private static ConnectorPartitionViewCache> listCache() {
- return new ConnectorPartitionViewCache<>("iceberg", Collections.emptyMap());
+ private static ConnectorMetadataCache> listCache() {
+ return new ConnectorMetadataCache<>("iceberg", "partition_view", Collections.emptyMap());
}
private static List mvccNames(Optional view) {
@@ -135,7 +135,7 @@ public void getMvccPartitionViewCachesDerivedViewAcrossQueries() {
TwoSnap f = twoSnapshotTable();
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
ops.table = f.table;
- ConnectorPartitionViewCache cache = mvccCache();
+ ConnectorMetadataCache cache = mvccCache();
IcebergConnectorMetadata md = metadataWithMvccCache(ops, cache);
Optional first = md.getMvccPartitionView(null, handle());
@@ -174,7 +174,7 @@ public void getMvccPartitionViewInvalidateTableForcesReEnumeration() {
TwoSnap f = twoSnapshotTable();
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
ops.table = f.table;
- ConnectorPartitionViewCache cache = mvccCache();
+ ConnectorMetadataCache cache = mvccCache();
IcebergConnectorMetadata md = metadataWithMvccCache(ops, cache);
md.getMvccPartitionView(null, handle());
@@ -208,7 +208,7 @@ public void listPartitionsCachesDerivedListAcrossQueries() {
TwoSnap f = twoSnapshotTable();
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
ops.table = f.table;
- ConnectorPartitionViewCache> cache = listCache();
+ ConnectorMetadataCache> cache = listCache();
IcebergConnectorMetadata md = metadataWithListCache(ops, cache);
List first = md.listPartitions(null, handle(), Optional.empty());
@@ -229,7 +229,7 @@ public void listPartitionsWithFilterBypassesCache() {
TwoSnap f = twoSnapshotTable();
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
ops.table = f.table;
- ConnectorPartitionViewCache> cache = listCache();
+ ConnectorMetadataCache> cache = listCache();
IcebergConnectorMetadata md = metadataWithListCache(ops, cache);
ConnectorExpression filter = Collections::emptyList; // any non-empty filter
@@ -249,7 +249,7 @@ public void listPartitionsInvalidateAllForcesReEnumeration() {
TwoSnap f = twoSnapshotTable();
RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
ops.table = f.table;
- ConnectorPartitionViewCache> cache = listCache();
+ ConnectorMetadataCache> cache = listCache();
IcebergConnectorMetadata md = metadataWithListCache(ops, cache);
md.listPartitions(null, handle(), Optional.empty());
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
index 70238ea323f82d..c7050a6a24940a 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java
@@ -28,10 +28,13 @@
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
/**
* Tests for {@link IcebergStatementScope}: the per-statement table memo keying (catalog id + db + table +
@@ -102,6 +105,34 @@ public void underNoneScopeLoadsEveryTime() {
Assertions.assertEquals(2, loads.get(), "NONE -> load every time");
}
+ @Test
+ public void underNullSessionLoadsEveryTime() {
+ // Offline / direct-construction (null session): sharedTable loads every time, byte-identical to the
+ // pre-scope behavior. The null branch now lives in the shared helper; assert it still holds at the seam.
+ AtomicInteger loads = new AtomicInteger();
+ IcebergStatementScope.sharedTable(null, "db1", "t", () -> {
+ loads.incrementAndGet();
+ return table("t");
+ });
+ IcebergStatementScope.sharedTable(null, "db1", "t", () -> {
+ loads.incrementAndGet();
+ return table("t");
+ });
+ Assertions.assertEquals(2, loads.get(), "null session -> load every time");
+ }
+
+ @Test
+ public void sharedTableKeyReproducesLegacyPrefixByteForByte() {
+ // PARITY (PR-2): sharedTable now delegates to ConnectorStatementScopes.resolveInStatement; the memo key it
+ // hands the scope MUST stay byte-identical to the pre-delegation
+ // "iceberg.table:" + catalogId + ":" + db + ":" + table + ":" + queryId, or funnel hits/misses shift.
+ // MUTATION: a different namespace, a dropped field, or a reordered field -> key differs -> red.
+ KeyCapturingScope scope = new KeyCapturingScope();
+ IcebergStatementScope.sharedTable(new ScopeSession(7L, "q1", scope), "db1", "t", () -> table("t"));
+ Assertions.assertEquals("iceberg.table:7:db1:t:q1", scope.lastKey,
+ "delegated key must reproduce the legacy iceberg.table prefix byte-for-byte");
+ }
+
@Test
public void rewritableDeleteSupplyIsSharedPerStatementAndIsolatedPerCatalog() {
// The scan seam and the write seam of one statement (same catalog + queryId) share ONE supply map; a
@@ -119,6 +150,39 @@ public void rewritableDeleteSupplyIsSharedPerStatementAndIsolatedPerCatalog() {
Assertions.assertNotSame(supplyScan, supplyOtherCatalog, "a different catalog (cross-catalog MERGE) is isolated");
}
+ @Test
+ public void allNamespacesArePrefixedWithConnectorType() throws Exception {
+ // NORM (self-extending): reflect over every "*_NAMESPACE" constant this connector declares and assert each
+ // is prefixed with the connector's ConnectorProvider.getType() ("iceberg."). Source-prefixing keeps the
+ // namespaces distinct across connectors on a heterogeneous gateway (no ClassCastException on the shared
+ // coordinate). Reflecting means a NEW namespace is auto-covered; a forgotten prefix or a getType() drift
+ // turns this red with no test upkeep.
+ String prefix = new IcebergConnectorProvider().getType() + ".";
+ int checked = 0;
+ for (Field f : IcebergStatementScope.class.getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers()) && f.getType() == String.class
+ && f.getName().endsWith("_NAMESPACE")) {
+ f.setAccessible(true);
+ String ns = (String) f.get(null);
+ Assertions.assertTrue(ns.startsWith(prefix),
+ f.getName() + " (\"" + ns + "\") must be prefixed with the connector type \"" + prefix + "\"");
+ checked++;
+ }
+ }
+ Assertions.assertTrue(checked > 0, "expected at least one *_NAMESPACE constant to guard");
+ }
+
+ /** A scope that records the last key handed to {@link #computeIfAbsent}, for the byte-key parity assertion. */
+ private static final class KeyCapturingScope implements ConnectorStatementScope {
+ private String lastKey;
+
+ @Override
+ public T computeIfAbsent(String key, Supplier loader) {
+ lastKey = key;
+ return loader.get();
+ }
+ }
+
/** Minimal {@link ConnectorSession} carrying a catalog id, queryId and scope for the key + memo assertions. */
private static final class ScopeSession implements ConnectorSession {
private final long catalogId;
@@ -141,6 +205,14 @@ public String getQueryId() {
return queryId;
}
+ @Override
+ public String getSessionId() {
+ // Deliberately != queryId. The memo key MUST use the per-EXECUTION queryId (cross-query isolation), not
+ // the stable per-connection sessionId; a queryId->sessionId swap in the key would share a table across
+ // queries of one connection and MUST turn sharedTableKeyReproducesLegacyPrefixByteForByte red.
+ return "session-" + queryId;
+ }
+
@Override
public ConnectorStatementScope getStatementScope() {
return scope;
diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java
index c20a60318cf7a5..4897367e57fa06 100644
--- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java
@@ -20,6 +20,7 @@
import org.apache.doris.connector.api.ConnectorColumn;
import org.apache.doris.connector.api.ConnectorMetadata;
import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScopes;
import org.apache.doris.connector.api.ConnectorTableSchema;
import org.apache.doris.connector.api.ConnectorTableStatistics;
import org.apache.doris.connector.api.ConnectorType;
@@ -49,6 +50,14 @@ public class JdbcConnectorMetadata implements ConnectorMetadata {
private static final Logger LOG = LogManager.getLogger(JdbcConnectorMetadata.class);
+ /**
+ * Namespace for jdbc's per-statement RAW remote-columns memo (a {@code List}), shared by the
+ * schema path ({@link #getTableSchema}), the scan column-handle path ({@link #getColumnHandles}) and the write
+ * INSERT-SQL shaping ({@code JdbcWritePlanProvider#buildInsertSql}). Source-prefixed with the connector type
+ * ("jdbc") so it stays distinct across a heterogeneous gateway; see {@link ConnectorStatementScopes}.
+ */
+ static final String COLUMNS_NAMESPACE = "jdbc.columns";
+
private final JdbcConnectorClient client;
private final Map properties;
@@ -116,7 +125,9 @@ public ConnectorTableSchema getTableSchema(
String dbName = jdbcHandle.getRemoteDbName();
String tableName = jdbcHandle.getRemoteTableName();
- List fields = client.getJdbcColumnsInfo(dbName, tableName);
+ List fields = ConnectorStatementScopes.resolveInStatement(
+ session, COLUMNS_NAMESPACE, dbName, tableName,
+ () -> client.getJdbcColumnsInfo(dbName, tableName));
List columns = new ArrayList<>(fields.size());
for (JdbcFieldInfo field : fields) {
@@ -159,7 +170,9 @@ public Map getColumnHandles(
String tableName = jdbcHandle.getRemoteTableName();
JdbcIdentifierMapper mapper = getIdentifierMapper(session);
- List fields = client.getJdbcColumnsInfo(dbName, tableName);
+ List fields = ConnectorStatementScopes.resolveInStatement(
+ session, COLUMNS_NAMESPACE, dbName, tableName,
+ () -> client.getJdbcColumnsInfo(dbName, tableName));
Map handles = new LinkedHashMap<>(fields.size());
for (JdbcFieldInfo field : fields) {
String remoteName = field.getColumnName();
diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcWritePlanProvider.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcWritePlanProvider.java
index 9ed3d56df57dd3..9fc66961d2e5ed 100644
--- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcWritePlanProvider.java
+++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcWritePlanProvider.java
@@ -126,6 +126,12 @@ public void appendExplainInfo(StringBuilder output, String prefix,
* {@link #appendExplainInfo} so the EXPLAIN SQL is identical to the one sent to BE. Resolves
* the local -> remote column name mapping via the metadata column handles (same client +
* properties as the legacy {@code getWriteConfig}, which called its own getColumnHandles).
+ *
+ * The {@code new JdbcConnectorMetadata} is a cheap stateless wrapper; its
+ * {@link JdbcConnectorMetadata#getColumnHandles} resolves the raw remote-column fetch through the
+ * per-statement scope memo ({@code JdbcConnectorMetadata.COLUMNS_NAMESPACE}), so it shares the single
+ * {@code getJdbcColumnsInfo} round-trip with the read path and with the sibling
+ * planWrite/appendExplainInfo call of the same statement — EXPLAIN INSERT no longer double-fetches.
*/
private String buildInsertSql(ConnectorSession session, JdbcTableHandle jdbcHandle,
List columns) {
diff --git a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadataTest.java b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadataTest.java
index d431873e8b69e0..0546eb09ee09ba 100644
--- a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadataTest.java
+++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadataTest.java
@@ -17,15 +17,30 @@
package org.apache.doris.connector.jdbc;
+import org.apache.doris.connector.api.ConnectorColumn;
import org.apache.doris.connector.api.ConnectorPushdownOps;
import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScope;
+import org.apache.doris.connector.api.ConnectorTableSchema;
+import org.apache.doris.connector.api.ConnectorType;
+import org.apache.doris.connector.api.handle.ConnectorColumnHandle;
+import org.apache.doris.connector.jdbc.client.JdbcConnectorClient;
+import org.apache.doris.connector.jdbc.client.JdbcFieldInfo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
/**
* Unit tests for {@link JdbcConnectorMetadata}.
@@ -33,6 +48,10 @@
class JdbcConnectorMetadataTest {
private ConnectorSession sessionWithProps(Map props) {
+ return sessionWithScope(props, ConnectorStatementScope.NONE);
+ }
+
+ private ConnectorSession sessionWithScope(Map props, ConnectorStatementScope scope) {
return new ConnectorSession() {
@Override
public String getQueryId() {
@@ -78,9 +97,92 @@ public Map getCatalogProperties() {
public Map getSessionProperties() {
return props;
}
+
+ @Override
+ public ConnectorStatementScope getStatementScope() {
+ return scope;
+ }
+ };
+ }
+
+ /** Live per-statement scope (map-backed) so resolveInStatement memoizes within the statement. */
+ private static ConnectorStatementScope liveScope() {
+ return new ConnectorStatementScope() {
+ private final Map arena = new HashMap<>();
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T computeIfAbsent(String key, Supplier loader) {
+ return (T) arena.computeIfAbsent(key, k -> loader.get());
+ }
};
}
+ /** Test double that counts the remote column fetches (getJdbcColumnsInfo round-trips). */
+ private static final class CountingJdbcClient extends JdbcConnectorClient {
+ private final AtomicInteger columnsFetches = new AtomicInteger();
+ private final List fields;
+
+ private CountingJdbcClient(List fields) {
+ super("test_catalog", JdbcDbType.MYSQL, "jdbc:mysql://h:3306/test_db",
+ false, null, null, false, false);
+ this.fields = fields;
+ }
+
+ @Override
+ public List getJdbcColumnsInfo(String remoteDbName, String remoteTableName) {
+ columnsFetches.incrementAndGet();
+ return fields;
+ }
+
+ @Override
+ public ConnectorType jdbcTypeToConnectorType(JdbcFieldInfo fieldInfo) {
+ return ConnectorType.of("INT");
+ }
+ }
+
+ private static JdbcFieldInfo field(String name) {
+ return new JdbcFieldInfo(name, Optional.empty(), 0,
+ Optional.empty(), Optional.empty(), Optional.empty());
+ }
+
+ /**
+ * Like {@link CountingJdbcClient} but its type conversion MUTATES the field in place (allowNull=true) for
+ * column "d", mirroring the real MySQL/ClickHouse overrides that run on the now-shared raw memo.
+ */
+ private static final class MutatingJdbcClient extends JdbcConnectorClient {
+ private final AtomicInteger columnsFetches = new AtomicInteger();
+ private final List fields;
+
+ private MutatingJdbcClient(List fields) {
+ super("test_catalog", JdbcDbType.MYSQL, "jdbc:mysql://h:3306/test_db",
+ false, null, null, false, false);
+ this.fields = fields;
+ }
+
+ @Override
+ public List getJdbcColumnsInfo(String remoteDbName, String remoteTableName) {
+ columnsFetches.incrementAndGet();
+ return fields;
+ }
+
+ @Override
+ public ConnectorType jdbcTypeToConnectorType(JdbcFieldInfo fieldInfo) {
+ if ("d".equals(fieldInfo.getColumnName())) {
+ fieldInfo.setAllowNull(true); // idempotent, mirrors the real convertDateToNull path
+ }
+ return ConnectorType.of("INT");
+ }
+ }
+
+ private static List columnSummary(ConnectorTableSchema schema) {
+ List out = new ArrayList<>();
+ for (ConnectorColumn c : schema.getColumns()) {
+ out.add(c.getName() + ":" + c.isNullable());
+ }
+ return out;
+ }
+
@Test
void testSupportsCastPredicatePushdown_defaultTrue() {
JdbcConnectorMetadata metadata = new JdbcConnectorMetadata(null, Collections.emptyMap());
@@ -112,4 +214,88 @@ void testDefaultPushdownOps_alwaysTrue() {
ConnectorSession session = sessionWithProps(Collections.emptyMap());
Assertions.assertTrue(defaultOps.supportsCastPredicatePushdown(session));
}
+
+ @Test
+ void columnHandlesAndSchemaShareOneRemoteFetchPerStatement() {
+ // WHY (HP-1): within one statement the scan-path getColumnHandles (called ~2x per scan node) and a
+ // schema-cache-miss getTableSchema each hit client.getJdbcColumnsInfo -- a remote
+ // DatabaseMetaData.getColumns round-trip. Routing the raw fetch through the per-statement scope memo
+ // (JdbcConnectorMetadata.COLUMNS_NAMESPACE, keyed by (catalogId, db, table, queryId)) collapses them to
+ // ONE remote fetch. MUTATION: fetching directly on each call (pre-fix) -> counter 3 -> red.
+ CountingJdbcClient client = new CountingJdbcClient(Arrays.asList(field("id"), field("name")));
+ JdbcConnectorMetadata md = new JdbcConnectorMetadata(client, Collections.emptyMap());
+ ConnectorSession session = sessionWithScope(Collections.emptyMap(), liveScope());
+ JdbcTableHandle handle = new JdbcTableHandle("db", "t");
+
+ Map handles = md.getColumnHandles(session, handle);
+ md.getColumnHandles(session, handle);
+ md.getTableSchema(session, handle);
+
+ Assertions.assertEquals(1, client.columnsFetches.get(),
+ "getColumnHandles x2 + getTableSchema must share ONE remote column fetch per statement");
+ Assertions.assertEquals(2, handles.size());
+ Assertions.assertTrue(handles.containsKey("id") && handles.containsKey("name"),
+ "the mapping is still applied per call on the shared raw fetch");
+ }
+
+ @Test
+ void noneScopeFetchesColumnsEveryCall() {
+ // Parity: under ConnectorStatementScope.NONE (offline / no live statement -- the default session scope),
+ // the memo runs the loader on every call, byte-identical to the pre-fix always-fetch behavior.
+ // MUTATION: memoizing under NONE -> counter 1 -> red.
+ CountingJdbcClient client = new CountingJdbcClient(Collections.singletonList(field("id")));
+ JdbcConnectorMetadata md = new JdbcConnectorMetadata(client, Collections.emptyMap());
+ ConnectorSession session = sessionWithProps(Collections.emptyMap()); // default scope = NONE
+
+ JdbcTableHandle handle = new JdbcTableHandle("db", "t");
+ md.getColumnHandles(session, handle);
+ md.getColumnHandles(session, handle);
+
+ Assertions.assertEquals(2, client.columnsFetches.get(),
+ "under NONE scope each call must fetch (no cross-call memo) -- parity with pre-fix");
+ }
+
+ @Test
+ void getTableSchemaStableOverSharedMutatedMemoUnderMutatingTypeConversion() {
+ // WHY: getTableSchema's jdbcTypeToConnectorType mutates the field in place (allowNull->true for some
+ // date types); under the per-statement memo the raw list is SHARED across getTableSchema/getColumnHandles,
+ // so a later getTableSchema reads an already-mutated field. Pin that the mutation is idempotent and
+ // non-corrupting: two getTableSchema calls over the shared memo (with a getColumnHandles between, which
+ // reads only names) yield byte-identical columns and reflect allowNull=true for the mutated column "d".
+ // MUTATION: a non-idempotent field mutation, or getColumnHandles depending on the flag -> schemas diverge.
+ MutatingJdbcClient client = new MutatingJdbcClient(Arrays.asList(field("d"), field("id")));
+ JdbcConnectorMetadata md = new JdbcConnectorMetadata(client, Collections.emptyMap());
+ ConnectorSession session = sessionWithScope(Collections.emptyMap(), liveScope());
+ JdbcTableHandle handle = new JdbcTableHandle("db", "t");
+
+ List first = columnSummary(md.getTableSchema(session, handle));
+ md.getColumnHandles(session, handle);
+ List second = columnSummary(md.getTableSchema(session, handle));
+
+ Assertions.assertEquals(1, client.columnsFetches.get(), "one shared fetch across all three calls");
+ Assertions.assertEquals(first, second, "getTableSchema over the shared (mutated) memo is stable");
+ Assertions.assertTrue(first.contains("d:true"),
+ "the mutating type conversion's idempotent allowNull=true is reflected in the schema");
+ }
+
+ @Test
+ void allNamespacesArePrefixedWithConnectorType() throws Exception {
+ // NORM (self-extending): reflect over every "*_NAMESPACE" constant this connector declares and assert each
+ // is prefixed with the connector's ConnectorProvider.getType() ("jdbc."). Source-prefixing keeps it distinct
+ // from every other connector's namespaces on a heterogeneous gateway. Reflecting means a NEW namespace is
+ // auto-covered; a forgotten prefix or a getType() drift turns this red.
+ String prefix = new JdbcConnectorProvider().getType() + ".";
+ int checked = 0;
+ for (Field f : JdbcConnectorMetadata.class.getDeclaredFields()) {
+ if (Modifier.isStatic(f.getModifiers()) && f.getType() == String.class
+ && f.getName().endsWith("_NAMESPACE")) {
+ f.setAccessible(true);
+ String ns = (String) f.get(null);
+ Assertions.assertTrue(ns.startsWith(prefix),
+ f.getName() + " (\"" + ns + "\") must be prefixed with the connector type \"" + prefix + "\"");
+ checked++;
+ }
+ }
+ Assertions.assertTrue(checked > 0, "expected at least one *_NAMESPACE constant to guard");
+ }
}
diff --git a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java
index 62a698c3f0839a..46f6ff2c013a92 100644
--- a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java
+++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java
@@ -19,6 +19,7 @@
import org.apache.doris.connector.api.ConnectorColumn;
import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScope;
import org.apache.doris.connector.api.ConnectorType;
import org.apache.doris.connector.api.handle.ConnectorTableHandle;
import org.apache.doris.connector.api.handle.ConnectorWriteHandle;
@@ -40,6 +41,8 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
/**
* Byte-parity tests for {@link JdbcWritePlanProvider} (P6.3-T02 / RFC OQ-1).
@@ -59,6 +62,7 @@ class JdbcWritePlanProviderTest {
*/
private static final class FakeJdbcClient extends JdbcConnectorClient {
private final List fields;
+ private final AtomicInteger columnsFetches = new AtomicInteger();
private FakeJdbcClient(JdbcDbType dbType, List fields) {
super("test_catalog", dbType, "jdbc:mysql://h:3306/test_db",
@@ -68,6 +72,7 @@ private FakeJdbcClient(JdbcDbType dbType, List fields) {
@Override
public List getJdbcColumnsInfo(String remoteDbName, String remoteTableName) {
+ columnsFetches.incrementAndGet();
return fields;
}
@@ -111,6 +116,23 @@ public Map getWriteContext() {
}
private static ConnectorSession session(long catalogId, Map sessionProps) {
+ return session(catalogId, sessionProps, ConnectorStatementScope.NONE);
+ }
+
+ private static ConnectorStatementScope liveScope() {
+ return new ConnectorStatementScope() {
+ private final Map arena = new HashMap<>();
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public T computeIfAbsent(String key, Supplier loader) {
+ return (T) arena.computeIfAbsent(key, k -> loader.get());
+ }
+ };
+ }
+
+ private static ConnectorSession session(long catalogId, Map sessionProps,
+ ConnectorStatementScope scope) {
return new ConnectorSession() {
@Override
public String getQueryId() {
@@ -156,6 +178,11 @@ public Map getCatalogProperties() {
public Map getSessionProperties() {
return sessionProps;
}
+
+ @Override
+ public ConnectorStatementScope getStatementScope() {
+ return scope;
+ }
};
}
@@ -268,4 +295,56 @@ void planWritePoolAndTxnFieldsFallBackToLegacyDefaultsWhenAbsent() {
// enable_odbc_transcation absent -> false (note the legacy session-key spelling preserved).
Assertions.assertFalse(sink.isUseTransaction());
}
+
+ @Test
+ void explainInsertSharesOneColumnFetchWithinStatement() {
+ // WHY (HP-2): planWrite and appendExplainInfo each shape the INSERT SQL via getColumnHandles ->
+ // getJdbcColumnsInfo; an EXPLAIN INSERT fires both on the same session -> two remote column fetches
+ // before. Routing that fetch through the per-statement scope collapses them to ONE. MUTATION: newing a
+ // fresh stateless JdbcConnectorMetadata that re-fetches (pre-fix) -> counter 2 -> red.
+ Map props = new HashMap<>();
+ props.put("jdbc_url", "jdbc:mysql://h:3306/test_db");
+ FakeJdbcClient client = new FakeJdbcClient(
+ JdbcDbType.MYSQL, Arrays.asList(field("id"), field("name")));
+ JdbcWritePlanProvider provider = new JdbcWritePlanProvider(client, props);
+ ConnectorSession session = session(7L, Collections.emptyMap(), liveScope());
+ ConnectorWriteHandle handle = writeHandle(
+ new JdbcTableHandle("test_db", "t1"), Arrays.asList("id", "name"));
+
+ ConnectorSinkPlan plan = provider.planWrite(session, handle);
+ StringBuilder sb = new StringBuilder();
+ provider.appendExplainInfo(sb, " ", session, handle);
+
+ Assertions.assertEquals(1, client.columnsFetches.get(),
+ "planWrite + appendExplainInfo must share ONE remote column fetch per statement");
+ // byte-parity of the produced SQL is unaffected by the memo
+ Assertions.assertEquals("INSERT INTO `test_db`.`t1`(`id`,`name`) VALUES (?, ?)",
+ plan.getDataSink().getJdbcTableSink().getInsertSql());
+ Assertions.assertTrue(sb.toString().contains(
+ "INSERT SQL: INSERT INTO `test_db`.`t1`(`id`,`name`) VALUES (?, ?)"));
+ }
+
+ @Test
+ void scanAndWriteShareOneColumnFetchWithinStatement() {
+ // WHY (HP-1 + HP-2 composition): because the memo lives in the statement SCOPE (not the metadata
+ // instance), the scan-path getColumnHandles and the write-path buildInsertSql -- even though the write
+ // provider news up its own JdbcConnectorMetadata -- share the ONE fetch for the same (catalog, db,
+ // table, query). MUTATION: an instance-level memo instead of scope-keyed -> the write's fresh instance
+ // misses -> counter 2 -> red.
+ Map props = new HashMap<>();
+ props.put("jdbc_url", "jdbc:mysql://h:3306/test_db");
+ FakeJdbcClient client = new FakeJdbcClient(
+ JdbcDbType.MYSQL, Arrays.asList(field("id"), field("name")));
+ ConnectorSession session = session(7L, Collections.emptyMap(), liveScope());
+ JdbcTableHandle tableHandle = new JdbcTableHandle("test_db", "t1");
+
+ // scan path resolves column handles on the funnel metadata
+ new JdbcConnectorMetadata(client, props).getColumnHandles(session, tableHandle);
+ // write path news up its own metadata inside buildInsertSql, yet shares the same scope entry
+ JdbcWritePlanProvider provider = new JdbcWritePlanProvider(client, props);
+ provider.planWrite(session, writeHandle(tableHandle, Arrays.asList("id", "name")));
+
+ Assertions.assertEquals(1, client.columnsFetches.get(),
+ "scan getColumnHandles and write buildInsertSql must share ONE fetch (scope-keyed, not instance)");
+ }
}
diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java
index f70935a73e593b..f07ed7ea2430ef 100644
--- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java
@@ -54,6 +54,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
/**
* ConnectorMetadata implementation for MaxCompute.
@@ -78,6 +79,15 @@ public class MaxComputeConnectorMetadata implements ConnectorMetadata {
private final Map properties;
private final MaxComputePartitionCache partitionCache;
+ // Per-statement table-handle memo. This metadata instance is created fresh per statement
+ // (MaxComputeDorisConnector.getMetadata), so a table resolved once -- one remote exists()
+ // probe plus one lazy ODPS Table -- is reused by every planning site in the same statement
+ // instead of each site re-probing and rebuilding it (killing the redundant exists() round
+ // trips and the per-Table schema reload). ConcurrentHashMap because a scan reuses the same
+ // per-statement session (hence this instance) across off-thread pool tasks. Key is the
+ // (dbName, tableName) pair via List's value equality -- no separator collision to reason about.
+ private final Map, MaxComputeTableHandle> tableHandleMemo = new ConcurrentHashMap<>();
+
public MaxComputeConnectorMetadata(Odps odps,
McStructureHelper structureHelper,
String defaultProject,
@@ -118,15 +128,24 @@ public boolean tableExists(ConnectorSession session, String dbName,
@Override
public Optional getTableHandle(
ConnectorSession session, String dbName, String tableName) {
- if (!structureHelper.tableExist(odps, dbName, tableName)) {
- return Optional.empty();
- }
- Table odpsTable = structureHelper.getOdpsTable(
- odps, dbName, tableName);
- TableIdentifier tableId = structureHelper.getTableIdentifier(
- dbName, tableName);
- return Optional.of(new MaxComputeTableHandle(
- dbName, tableName, odpsTable, tableId));
+ // Only present tables are memoized: returning null from the mapping function records no
+ // mapping, so an absent table re-probes on every call -- keeping the Optional.empty()
+ // ("table not found") behavior byte-identical to before. computeIfAbsent runs the
+ // mapping function at most once per key even under a concurrent first touch, so the
+ // exists() probe fires once per (db, table) per statement.
+ MaxComputeTableHandle handle = tableHandleMemo.computeIfAbsent(
+ List.of(dbName, tableName), k -> {
+ if (!structureHelper.tableExist(odps, dbName, tableName)) {
+ return null;
+ }
+ Table odpsTable = structureHelper.getOdpsTable(
+ odps, dbName, tableName);
+ TableIdentifier tableId = structureHelper.getTableIdentifier(
+ dbName, tableName);
+ return new MaxComputeTableHandle(
+ dbName, tableName, odpsTable, tableId);
+ });
+ return Optional.ofNullable(handle);
}
@Override
@@ -323,10 +342,9 @@ public boolean supportsCastPredicatePushdown(ConnectorSession session) {
* matches the id registered in the engine transaction registry and stamped
* into the data sink (see {@link MaxComputeConnectorTransaction}).
*
- * Gate-closed / dormant until the {@code max_compute} cutover: nothing
- * routes plugin-driven MaxCompute writes through this path yet. The ODPS
- * write session that backs commit / block allocation is created by the write
- * plan (P4-T04), which binds it via
+ *
Live since the MaxCompute cutover: plugin-driven MaxCompute writes route
+ * through this path. The ODPS write session that backs commit / block
+ * allocation is created by the write plan, which binds it via
* {@link MaxComputeConnectorTransaction#setWriteSession}.
*/
@Override
diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransaction.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransaction.java
index deec881c53652f..af798df4492c72 100644
--- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransaction.java
+++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorTransaction.java
@@ -52,12 +52,12 @@
* write session — the session id / target identifier / environment settings used
* by {@link #commit()}.
*
- * Gate-closed / dormant. Nothing routes plugin-driven MaxCompute writes
- * through this class until the {@code max_compute} cutover: the executor wiring
+ *
Live since the MaxCompute cutover. Plugin-driven MaxCompute writes
+ * route through this class: the executor wiring
* ({@code beginTransaction} → {@code PluginDrivenTransactionManager.begin})
- * and {@code GlobalExternalTransactionInfoMgr} registration are deferred to that
- * step. {@link #commit()} depends on the write-session state populated by P4-T04
- * (via {@link #setWriteSession}); it is intentionally not runnable before then.
+ * and {@code GlobalExternalTransactionInfoMgr} registration are in place.
+ * {@link #commit()} depends on the write-session state populated by the write plan
+ * (via {@link #setWriteSession}).
*/
public class MaxComputeConnectorTransaction
implements ConnectorTransaction, WriteBlockAllocatingConnectorTransaction {
diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java
index f8da97fed69ef7..438897b3046930 100644
--- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java
+++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java
@@ -71,10 +71,9 @@
* engine transaction ({@link MaxComputeConnectorTransaction#allocateWriteBlockRange})
* keyed by {@code txn_id}.
*
- * Gate-closed / dormant. Nothing routes plugin-driven MaxCompute writes
- * through this provider until the {@code max_compute} cutover. In particular
- * {@link #planWrite} requires the session to carry the connector transaction
- * (bound by the executor wiring added at cutover); it fails loud if absent.
+ * Live since the MaxCompute cutover. A plugin-driven MaxCompute write
+ * routes through this provider. {@link #planWrite} requires the session to carry
+ * the connector transaction (bound by the executor wiring); it fails loud if absent.
*/
public class MaxComputeWritePlanProvider implements ConnectorWritePlanProvider {
diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataHandleMemoTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataHandleMemoTest.java
new file mode 100644
index 00000000000000..7d3bf31c721bae
--- /dev/null
+++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataHandleMemoTest.java
@@ -0,0 +1,230 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.maxcompute;
+
+import org.apache.doris.connector.api.handle.ConnectorTableHandle;
+
+import com.aliyun.odps.Odps;
+import com.aliyun.odps.OdpsException;
+import com.aliyun.odps.Partition;
+import com.aliyun.odps.Table;
+import com.aliyun.odps.TableSchema;
+import com.aliyun.odps.Tables;
+import com.aliyun.odps.table.TableIdentifier;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Guards the per-statement table-handle memo added to {@link MaxComputeConnectorMetadata}.
+ *
+ * WHY this matters: a single MaxCompute table is resolved by ~a dozen independent
+ * planning sites within one statement (scan build, partition pruning, row-count, per-column
+ * stats, write-capability probes, SHOW CREATE). Each {@code getTableHandle} used to fire a
+ * fresh remote ODPS {@code tables().exists()} probe and build a new lazy {@code Table} whose
+ * first schema access triggers its own reload. Because the metadata instance is created fresh
+ * per statement (funnel-memoized one-per-statement), memoizing the resolved handle collapses
+ * those repeats to one probe + one Table per (db, table) per statement, and makes read and
+ * write share the same already-warmed Table.
+ *
+ * The maxcompute test module builds no live ODPS client, so a hand-written counting
+ * {@link McStructureHelper} records how many times the remote probe / lazy-Table build fire.
+ * {@code getTableHandle} never dereferences {@code odps} directly (it only forwards it to the
+ * helper), so a {@code null} odps keeps the test offline — the same pattern as
+ * {@link MaxComputeConnectorMetadataDropDbTest}.
+ */
+public class MaxComputeConnectorMetadataHandleMemoTest {
+
+ private MaxComputeConnectorMetadata metadataWith(McStructureHelper helper) {
+ return new MaxComputeConnectorMetadata(
+ null /* odps */, helper, "proj", "ep", "quota", Collections.emptyMap(),
+ null); // null: partition cache unused by this test
+ }
+
+ @Test
+ public void sameTableResolvedTwiceProbesAndBuildsOnce() {
+ CountingStructureHelper helper = new CountingStructureHelper("db1.t1");
+ MaxComputeConnectorMetadata metadata = metadataWith(helper);
+
+ Optional first = metadata.getTableHandle(null, "db1", "t1");
+ Optional second = metadata.getTableHandle(null, "db1", "t1");
+
+ // WHY: the second resolution of the same table in one statement must cost ZERO remote
+ // round trips and reuse the identical handle (hence the identical lazy Table, so the
+ // schema reload happens once). MUTATION: dropping the memo makes the second call
+ // re-probe/rebuild -> both lists size 2 (red) and the handles become distinct
+ // (assertSame red).
+ Assertions.assertTrue(first.isPresent() && second.isPresent(),
+ "an existing table must resolve to a present handle");
+ Assertions.assertEquals(Collections.singletonList("db1.t1"), helper.existProbes,
+ "a table resolved twice in one statement must hit the remote exists() probe only once");
+ Assertions.assertEquals(Collections.singletonList("db1.t1"), helper.odpsTableBuilds,
+ "the lazy ODPS Table must be built once and shared, not rebuilt per resolution");
+ Assertions.assertSame(first.get(), second.get(),
+ "both resolutions must return the identical memoized handle (shared Table => shared reload)");
+ }
+
+ @Test
+ public void distinctTablesAreResolvedIndependently() {
+ CountingStructureHelper helper = new CountingStructureHelper("db1.t1", "db1.t2");
+ MaxComputeConnectorMetadata metadata = metadataWith(helper);
+
+ ConnectorTableHandle h1 = metadata.getTableHandle(null, "db1", "t1").orElseThrow();
+ ConnectorTableHandle h2 = metadata.getTableHandle(null, "db1", "t2").orElseThrow();
+
+ // WHY: distinct (db, table) keys must not collide in the memo — each table is resolved
+ // exactly once on its own. This pins the value-equality List key (a broken key that
+ // collapsed the two names would return one shared handle -> assertNotSame red).
+ Assertions.assertNotSame(h1, h2, "different tables must resolve to different handles");
+ Assertions.assertEquals(Arrays.asList("db1.t1", "db1.t2"), helper.existProbes,
+ "each distinct table is probed once");
+ Assertions.assertEquals(Arrays.asList("db1.t1", "db1.t2"), helper.odpsTableBuilds,
+ "each distinct table builds its Table once");
+ }
+
+ @Test
+ public void sameTableNameInDifferentDatabasesDoNotCollide() {
+ CountingStructureHelper helper = new CountingStructureHelper("db1.t", "db2.t");
+ MaxComputeConnectorMetadata metadata = metadataWith(helper);
+
+ MaxComputeTableHandle a = (MaxComputeTableHandle) metadata.getTableHandle(null, "db1", "t").orElseThrow();
+ MaxComputeTableHandle b = (MaxComputeTableHandle) metadata.getTableHandle(null, "db2", "t").orElseThrow();
+
+ // WHY: the memo key must include the database, not just the table name. A statement that
+ // joins db1.t and db2.t routes both resolutions through the one per-statement metadata
+ // instance; a db-blind key would hand db1's handle back for db2.t -> the wrong project's
+ // Table resolved silently. MUTATION: keying on tableName only makes the second call hit
+ // db1's entry -> b == a (assertNotSame red), b.getDbName() == "db1" (red), and existProbes
+ // drops to size 1 (red).
+ Assertions.assertNotSame(a, b, "same table name in different databases must not share a memo entry");
+ Assertions.assertEquals("db1", a.getDbName(), "first handle must carry its own database");
+ Assertions.assertEquals("db2", b.getDbName(), "second handle must resolve db2, not reuse db1's handle");
+ Assertions.assertEquals(Arrays.asList("db1.t", "db2.t"), helper.existProbes,
+ "each database's table is probed independently");
+ }
+
+ @Test
+ public void absentTableReprobesEachCallAndIsNeverMemoized() {
+ CountingStructureHelper helper = new CountingStructureHelper(); // nothing present
+ MaxComputeConnectorMetadata metadata = metadataWith(helper);
+
+ Optional first = metadata.getTableHandle(null, "db1", "missing");
+ Optional second = metadata.getTableHandle(null, "db1", "missing");
+
+ // WHY: negatives are intentionally NOT memoized, so a missing table keeps the exact
+ // pre-change behavior — a fresh exists() probe and a clean Optional.empty() on EVERY
+ // call, never a cached present handle. MUTATION: caching the empty result would drop
+ // existProbes to size 1 -> red.
+ Assertions.assertFalse(first.isPresent(), "a missing table must resolve to empty");
+ Assertions.assertFalse(second.isPresent(), "a missing table must resolve to empty every time");
+ Assertions.assertEquals(Arrays.asList("db1.missing", "db1.missing"), helper.existProbes,
+ "an absent table must re-probe on every call (negatives are not memoized)");
+ Assertions.assertTrue(helper.odpsTableBuilds.isEmpty(),
+ "an absent table must never build an ODPS Table handle");
+ }
+
+ /**
+ * Counting fake: a table "exists" iff its {@code db.table} was passed to the constructor.
+ * Records every {@code tableExist} probe and every {@code getOdpsTable} build so the tests
+ * can assert the memo collapses repeats. All other methods return harmless defaults — they
+ * are never invoked by {@code getTableHandle}.
+ */
+ private static final class CountingStructureHelper implements McStructureHelper {
+ private final Set presentTables;
+ private final List existProbes = new ArrayList<>();
+ private final List odpsTableBuilds = new ArrayList<>();
+
+ CountingStructureHelper(String... present) {
+ this.presentTables = new HashSet<>(Arrays.asList(present));
+ }
+
+ @Override
+ public boolean tableExist(Odps mcClient, String dbName, String tableName) {
+ String key = dbName + "." + tableName;
+ existProbes.add(key);
+ return presentTables.contains(key);
+ }
+
+ @Override
+ public Table getOdpsTable(Odps mcClient, String dbName, String tableName) {
+ odpsTableBuilds.add(dbName + "." + tableName);
+ // A null Table is enough: the tests assert handle identity and build counts, never
+ // touch the Table itself, keeping the fake offline.
+ return null;
+ }
+
+ @Override
+ public TableIdentifier getTableIdentifier(String dbName, String tableName) {
+ return null;
+ }
+
+ // ---- unused by getTableHandle: harmless defaults ----
+
+ @Override
+ public List listTableNames(Odps mcClient, String dbName) {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List listDatabaseNames(Odps mcClient, String defaultProject) {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean databaseExist(Odps mcClient, String dbName) {
+ return false;
+ }
+
+ @Override
+ public List getPartitions(Odps mcClient, String dbName, String tableName) {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public Iterator getPartitionIterator(Odps mcClient, String dbName, String tableName) {
+ return Collections.emptyIterator();
+ }
+
+ @Override
+ public Tables.TableCreator createTableCreator(Odps mcClient, String dbName,
+ String tableName, TableSchema schema) {
+ return null;
+ }
+
+ @Override
+ public void dropTable(Odps mcClient, String dbName, String tableName, boolean ifExists)
+ throws OdpsException {
+ }
+
+ @Override
+ public void createDb(Odps mcClient, String dbName, boolean ifNotExists) {
+ }
+
+ @Override
+ public void dropDb(Odps mcClient, String dbName, boolean ifExists) {
+ }
+ }
+}
diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
index 12c85993a69f94..dc22d050fcba56 100644
--- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
+++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
@@ -24,7 +24,7 @@
import org.apache.doris.connector.api.ConnectorSession;
import org.apache.doris.connector.api.ConnectorValidationContext;
import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
import org.apache.doris.connector.metastore.HmsMetaStoreProperties;
import org.apache.doris.connector.metastore.spi.JdbcDriverSupport;
import org.apache.doris.connector.metastore.spi.MetaStoreProviders;
@@ -129,18 +129,19 @@ public class PaimonConnector implements Connector {
// Cleared wholesale on REFRESH CATALOG (the connector is rebuilt). See PaimonSchemaAtMemo.
private final PaimonSchemaAtMemo schemaAtMemo = new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE);
- // PERF-06: cross-query DERIVED partition-view cache ("cache A", the generic ConnectorPartitionViewCache from
+ // PERF-06: cross-query DERIVED partition-view cache ("cache A", the generic ConnectorMetadataCache from
// fe-connector-cache), layered ABOVE the raw remote catalog.listPartitions call (PaimonCatalogOps#listPartitions):
// it memoizes the BUILT List (display-name rendering + null-sentinel normalization,
// see PaimonConnectorMetadata#collectPartitions) keyed by (db, table, snapshotId, schemaId), so a repeated
// query on a partitioned table skips the derived rebuild AND the remote catalog round-trip. ONE typed field
// (unlike iceberg's two): paimon does not override getMvccPartitionView, so the generic MTMV model falls
- // back to its default listPartitions/LIST/timestamp path for paimon -- listPartitions is the only
- // enumeration hook to wrap. Unlike iceberg, paimon has NO session=user / per-user credential-isolation
+ // back to its default listPartitions/LIST/timestamp path for paimon -- all three partition-enumeration
+ // hooks (listPartitions/Names/Values) share it via PaimonConnectorMetadata#cachedPartitions. Unlike
+ // iceberg, paimon has NO session=user / per-user credential-isolation
// cache-disabling convention (a paimon catalog authenticates at catalog-creation time -- Kerberos UGI /
// HMS principal -- not per-query session identity), so this is constructed unconditionally: never null on
// a live connector (only PaimonConnectorMetadata's convenience/test constructors pass null).
- private final ConnectorPartitionViewCache> partitionViewCache;
+ private final ConnectorMetadataCache> partitionViewCache;
public PaimonConnector(Map properties, ConnectorContext context) {
this.properties = properties;
@@ -155,7 +156,7 @@ public PaimonConnector(Map properties, ConnectorContext context)
new PaimonLatestSnapshotCache(resolveTableCacheTtlSecond(properties), DEFAULT_TABLE_CACHE_CAPACITY);
// Reads its own meta.cache.paimon.partition_view.(enable|ttl-second|capacity) from the catalog
// properties via the framework's CacheSpec (default ON / 24h / 1000).
- this.partitionViewCache = new ConnectorPartitionViewCache<>("paimon", properties);
+ this.partitionViewCache = new ConnectorMetadataCache<>("paimon", "partition_view", properties);
}
/**
@@ -341,7 +342,7 @@ public Set getCapabilities() {
}
/** Test-only: the derived listPartitions view cache (PERF-06). Never null (paimon has no session=user gate). */
- ConnectorPartitionViewCache> partitionViewCacheForTest() {
+ ConnectorMetadataCache> partitionViewCacheForTest() {
return partitionViewCache;
}
diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
index 3888a160e96185..2df695a8f8866e 100644
--- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java
@@ -32,8 +32,8 @@
import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec;
import org.apache.doris.connector.api.pushdown.ConnectorExpression;
import org.apache.doris.connector.api.scan.ConnectorPartitionValues;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
-import org.apache.doris.connector.cache.PartitionViewCacheKey;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
+import org.apache.doris.connector.cache.ConnectorTableKey;
import org.apache.doris.connector.spi.ConnectorContext;
import org.apache.doris.thrift.THiveTable;
import org.apache.doris.thrift.TTableDescriptor;
@@ -97,14 +97,15 @@ public class PaimonConnectorMetadata implements ConnectorMetadata {
// existing direct-construction tests compile unchanged; production goes through the 5-arg ctor.
private final PaimonLatestSnapshotCache latestSnapshotCache;
- // PERF-06: cross-query DERIVED partition-view cache A (generic ConnectorPartitionViewCache), injected by the
+ // PERF-06: cross-query DERIVED partition-view cache A (generic ConnectorMetadataCache), injected by the
// owning PaimonConnector; null = no cross-query derived layer (the convenience/test ctors used by ~15
// existing direct-construction tests pass null). Layered ABOVE the raw remote catalogOps.listPartitions
// call: a hit skips both the derived-view BUILD (collectPartitions) and the remote round-trip, keyed by
- // (db, table, snapshotId, schemaId). Consumed only by listPartitions -- paimon does not override
- // getMvccPartitionView (see ConnectorMetadata's default), so the generic MTMV model already uses
- // listPartitions for its LIST/timestamp partition view; there is no second hook to wrap.
- private final ConnectorPartitionViewCache> partitionViewCache;
+ // (db, table, snapshotId, schemaId). Consumed by all three partition-enumeration hooks (listPartitions,
+ // listPartitionNames, listPartitionValues) via the shared cachedPartitions collector -- paimon does not
+ // override getMvccPartitionView (see ConnectorMetadata's default), so the generic MTMV model already uses
+ // listPartitions for its LIST/timestamp partition view.
+ private final ConnectorMetadataCache> partitionViewCache;
public PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties,
ConnectorContext context) {
@@ -132,7 +133,7 @@ public PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map
PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties,
ConnectorContext context, PaimonSchemaAtMemo schemaAtMemo,
PaimonLatestSnapshotCache latestSnapshotCache,
- ConnectorPartitionViewCache> partitionViewCache) {
+ ConnectorMetadataCache> partitionViewCache) {
this.catalogOps = catalogOps;
this.typeMappingOptions = buildTypeMappingOptions(properties);
this.context = context;
@@ -1059,7 +1060,7 @@ private static Map buildColumnHandles(List listPartitionNames(ConnectorSession session, ConnectorTableHandle handle) {
- List partitions = collectPartitions((PaimonTableHandle) handle);
+ List partitions = cachedPartitions((PaimonTableHandle) handle);
List names = new ArrayList<>(partitions.size());
for (ConnectorPartitionInfo partition : partitions) {
names.add(partition.getPartitionName());
@@ -1073,26 +1074,42 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH
* pushing predicates into the Paimon catalog, and this preserves that behavior (mirrors
* {@code MaxComputeConnectorMetadata}).
*
- * PERF-06 cache A: the BUILT {@code List} is memoized across queries in
- * {@link #partitionViewCache}, keyed by {@code (db, table, snapshotId, schemaId)} (see
- * {@link #partitionViewCacheKey}) — a hit skips both {@link #collectPartitions} and the remote
- * {@code catalogOps.listPartitions} round-trip. The cache is BYPASSED (compute directly, never populated)
- * when: {@code partitionViewCache} is {@code null} (the convenience/test ctors); {@code filter} is present
- * (not the pruning path, and not keyed by filter); or the handle is unpartitioned (mirrors
- * {@link #collectPartitions}'s own empty-partitionKeys short-circuit, so an unpartitioned table never
- * touches {@link #latestSnapshotCache} either — preserving the "no seam call" contract the unpartitioned
- * path already guarantees).
+ * A present {@code filter} BYPASSES the derived cache (computes directly, never populates) — it is
+ * not the pruning path and not keyed by filter. Every other case routes through {@link #cachedPartitions},
+ * the shared cache-aware collector this hook shares with {@link #listPartitionNames} and
+ * {@link #listPartitionValues}.
*/
@Override
public List listPartitions(ConnectorSession session,
ConnectorTableHandle handle, Optional filter) {
PaimonTableHandle paimonHandle = (PaimonTableHandle) handle;
+ if (filter.isPresent()) {
+ return collectPartitions(paimonHandle);
+ }
+ return cachedPartitions(paimonHandle);
+ }
+
+ /**
+ * Shared cache-aware partition collector backing the no-filter path of {@link #listPartitions},
+ * plus {@link #listPartitionNames} and {@link #listPartitionValues}. Returns the BUILT
+ * {@code List} from {@link #partitionViewCache} (PERF-06 cache A), keyed by
+ * {@code (db, table, snapshotId, schemaId)} (see {@link #partitionViewCacheKey}) — a hit skips both
+ * {@link #collectPartitions} and the remote {@code catalogOps.listPartitions} round-trip, so repeated
+ * SHOW PARTITIONS / {@code partition_values()} / pruning over the same {@code (db, table, snapshotId)}
+ * render the list once.
+ *
+ * The cache is BYPASSED (compute directly via {@link #collectPartitions}, never populated) when
+ * {@code partitionViewCache} is {@code null} (the convenience/test ctors) or the handle is unpartitioned
+ * (mirrors {@link #collectPartitions}'s own empty-partitionKeys short-circuit, so an unpartitioned table
+ * never touches {@link #latestSnapshotCache} either — preserving the "no seam call" contract the
+ * unpartitioned path already guarantees).
+ */
+ private List cachedPartitions(PaimonTableHandle paimonHandle) {
List partitionKeys = paimonHandle.getPartitionKeys();
- if (partitionViewCache == null || filter.isPresent()
- || partitionKeys == null || partitionKeys.isEmpty()) {
+ if (partitionViewCache == null || partitionKeys == null || partitionKeys.isEmpty()) {
return collectPartitions(paimonHandle);
}
- PartitionViewCacheKey key = partitionViewCacheKey(paimonHandle);
+ ConnectorTableKey key = partitionViewCacheKey(paimonHandle);
return partitionViewCache.get(key, () -> collectPartitions(paimonHandle));
}
@@ -1109,7 +1126,7 @@ public List listPartitions(ConnectorSession session,
* and a new snapshot (data change, once the entry expires or REFRESH invalidates it) naturally mints a new key.
*
* schemaId: pinned {@code -1} ("unversioned" for that axis, matching
- * {@link PartitionViewCacheKey}'s documented convention). Unlike iceberg, paimon's {@link PaimonTableHandle}
+ * {@link ConnectorTableKey}'s documented convention). Unlike iceberg, paimon's {@link PaimonTableHandle}
* carries no schemaId — {@code applySnapshot} threads only {@code scanOptions} (an opaque properties map;
* see its javadoc) onto the handle, and {@link #beginQuerySnapshot} (the common latest-pin path) never
* resolves a schemaId either (its {@code ConnectorMvccSnapshot} keeps the builder default {@code -1}). This
@@ -1117,18 +1134,18 @@ public List listPartitions(ConnectorSession session,
* (fixed at handle-build time) and paimon's raw partition specs, and paimon partition columns are immutable
* post-creation, so schema evolution (e.g. ADD COLUMN) does not change what this method computes.
*/
- private PartitionViewCacheKey partitionViewCacheKey(PaimonTableHandle paimonHandle) {
+ private ConnectorTableKey partitionViewCacheKey(PaimonTableHandle paimonHandle) {
Identifier identifier = Identifier.create(paimonHandle.getDatabaseName(), paimonHandle.getTableName());
long snapshotId = latestSnapshotCache.getOrLoad(identifier,
() -> catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L));
- return new PartitionViewCacheKey(
+ return new ConnectorTableKey(
paimonHandle.getDatabaseName(), paimonHandle.getTableName(), snapshotId, -1L);
}
@Override
public List> listPartitionValues(ConnectorSession session,
ConnectorTableHandle handle, List partitionColumns) {
- List partitions = collectPartitions((PaimonTableHandle) handle);
+ List partitions = cachedPartitions((PaimonTableHandle) handle);
List> result = new ArrayList<>(partitions.size());
for (ConnectorPartitionInfo partition : partitions) {
Map rawValues = partition.getPartitionValues();
@@ -1144,8 +1161,9 @@ public List> listPartitionValues(ConnectorSession session,
}
/**
- * Shared partition collector backing {@link #listPartitionNames}, {@link #listPartitions} and
- * {@link #listPartitionValues}. Replicates the fe-core display-name logic
+ * Shared (uncached) partition collector behind {@link #cachedPartitions} — the underlying compute for
+ * {@link #listPartitionNames}, {@link #listPartitions} and {@link #listPartitionValues}, also reached
+ * directly on the filter / unpartitioned / null-cache bypass. Replicates the fe-core display-name logic
* ({@code PaimonUtil.generatePartitionInfo} + {@code isLegacyPartitionName}) so the rendered
* partition names stay byte-identical to fe-core — including #65904, which drives value order from
* the partition columns and escapes path-special characters in the name via the Paimon SDK.
diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java
index 33b62686d07386..eb538c7d29f0e2 100644
--- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java
+++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonLatestSnapshotCache.java
@@ -50,13 +50,8 @@ final class PaimonLatestSnapshotCache {
private final MetaCacheEntry entry;
PaimonLatestSnapshotCache(long ttlSeconds, int maxSize) {
- // ttl-second <= 0 disables caching (always read live); a positive ttl is access-based expiry with the
- // given capacity. CacheSpec treats ttl == -1 as "no expiration (enabled)" and ttl == 0 as "disabled",
- // so translate the connector's "<= 0 disables" contract to ttl == 0 rather than passing a negative
- // value straight through (which would otherwise flip -1 into a never-expiring cache).
- CacheSpec spec = ttlSeconds > 0
- ? CacheSpec.of(true, ttlSeconds, maxSize)
- : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize);
+ // "<= 0 disables" connector TTL contract, folded to CacheSpec's disable sentinel (CacheSpec.ofConnectorTtl).
+ CacheSpec spec = CacheSpec.ofConnectorTtl(ttlSeconds, maxSize);
this.entry = new MetaCacheEntry<>("paimon-latest-snapshot", null, spec,
ForkJoinPool.commonPool(), false, true, 0L, true);
}
diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorCacheTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorCacheTest.java
index f952e6d47a7def..ce6773d67d29ea 100644
--- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorCacheTest.java
+++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorCacheTest.java
@@ -18,8 +18,8 @@
package org.apache.doris.connector.paimon;
import org.apache.doris.connector.api.ConnectorPartitionInfo;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
-import org.apache.doris.connector.cache.PartitionViewCacheKey;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
+import org.apache.doris.connector.cache.ConnectorTableKey;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -113,16 +113,16 @@ public void refreshHooksInvalidatePartitionViewCache() {
// invalidation the loader must run again. MUTATION: an invalidate* hook not routed to the view cache
// -> the entry survives -> loader not re-run -> a loads assert below red.
PaimonConnector connector = connector(Collections.emptyMap());
- ConnectorPartitionViewCache> cache = connector.partitionViewCacheForTest();
+ ConnectorMetadataCache> cache = connector.partitionViewCacheForTest();
Assertions.assertNotNull(cache);
int[] loads = {0};
Supplier> loader = () -> {
loads[0]++;
return Collections.emptyList();
};
- PartitionViewCacheKey db1t1 = new PartitionViewCacheKey("db1", "t1", 1L, -1L);
- PartitionViewCacheKey db1t2 = new PartitionViewCacheKey("db1", "t2", 1L, -1L);
- PartitionViewCacheKey db2t1 = new PartitionViewCacheKey("db2", "t1", 1L, -1L);
+ ConnectorTableKey db1t1 = new ConnectorTableKey("db1", "t1", 1L, -1L);
+ ConnectorTableKey db1t2 = new ConnectorTableKey("db1", "t2", 1L, -1L);
+ ConnectorTableKey db2t1 = new ConnectorTableKey("db2", "t1", 1L, -1L);
// REFRESH TABLE db1.t1 -> only db1.t1 re-loads.
cache.get(db1t1, loader);
diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java
index e93126549df16d..4c36d42053e4ce 100644
--- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java
+++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java
@@ -19,7 +19,7 @@
import org.apache.doris.connector.api.ConnectorPartitionInfo;
import org.apache.doris.connector.api.pushdown.ConnectorExpression;
-import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
+import org.apache.doris.connector.cache.ConnectorMetadataCache;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.types.DataTypes;
@@ -38,10 +38,11 @@
/**
* PERF-06 tests for the cross-query DERIVED partition-view cache ("cache A", the generic
- * {@link ConnectorPartitionViewCache}) wired into {@link PaimonConnectorMetadata#listPartitions}. Paimon does
- * NOT override {@code getMvccPartitionView} (the generic MTMV model falls back to its default
- * listPartitions/LIST/timestamp path), so — unlike iceberg's two typed fields — there is exactly ONE
- * enumeration hook to wrap.
+ * {@link ConnectorMetadataCache}) wired into all three partition-enumeration hooks
+ * ({@link PaimonConnectorMetadata#listPartitions}, {@code listPartitionNames}, {@code listPartitionValues})
+ * via the shared {@code cachedPartitions} collector. Paimon does NOT override {@code getMvccPartitionView}
+ * (the generic MTMV model falls back to its default listPartitions/LIST/timestamp path), so — unlike
+ * iceberg's two typed fields — there is a single typed cache field, now shared by all three hooks.
*
* Uses the real {@link RecordingPaimonCatalogOps} + {@link FakePaimonTable} harness (no Mockito, no docker):
* {@link PaimonConnectorMetadata#collectPartitions}'s first (and only) remote call is
@@ -57,14 +58,14 @@
public class PaimonConnectorMetadataPartitionViewCacheTest {
private static PaimonConnectorMetadata metadataWithCache(RecordingPaimonCatalogOps ops,
- ConnectorPartitionViewCache> cache) {
+ ConnectorMetadataCache> cache) {
return new PaimonConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext(),
new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE),
new PaimonLatestSnapshotCache(0L, 1), cache);
}
- private static ConnectorPartitionViewCache> partitionViewCache() {
- return new ConnectorPartitionViewCache<>("paimon", Collections.emptyMap());
+ private static ConnectorMetadataCache> partitionViewCache() {
+ return new ConnectorMetadataCache<>("paimon", "partition_view", Collections.emptyMap());
}
private static RowType regionRowType() {
@@ -113,7 +114,7 @@ public void listPartitionsCachesDerivedListAcrossQueries() {
ops.table = table;
ops.latestSnapshotId = OptionalLong.of(100L);
ops.partitions = Arrays.asList(partition("cn"), partition("us"));
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
PaimonConnectorMetadata md = metadataWithCache(ops, cache);
PaimonTableHandle h = handle(table);
@@ -135,7 +136,7 @@ public void listPartitionsDifferentSnapshotReEnumerates() {
RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
FakePaimonTable table = regionTable();
ops.table = table;
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
PaimonConnectorMetadata md = metadataWithCache(ops, cache);
PaimonTableHandle h = handle(table);
@@ -162,7 +163,7 @@ public void listPartitionsInvalidateTableForcesReEnumeration() {
ops.table = table;
ops.latestSnapshotId = OptionalLong.of(100L);
ops.partitions = Collections.singletonList(partition("cn"));
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
PaimonConnectorMetadata md = metadataWithCache(ops, cache);
PaimonTableHandle h = handle(table);
@@ -181,7 +182,7 @@ public void listPartitionsInvalidateAllForcesReEnumeration() {
ops.table = table;
ops.latestSnapshotId = OptionalLong.of(100L);
ops.partitions = Collections.singletonList(partition("cn"));
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
PaimonConnectorMetadata md = metadataWithCache(ops, cache);
PaimonTableHandle h = handle(table);
@@ -205,7 +206,7 @@ public void listPartitionsWithFilterBypassesCache() {
ops.table = table;
ops.latestSnapshotId = OptionalLong.of(100L);
ops.partitions = Collections.singletonList(partition("cn"));
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
PaimonConnectorMetadata md = metadataWithCache(ops, cache);
PaimonTableHandle h = handle(table);
@@ -251,7 +252,7 @@ public void unpartitionedHandleBypassesCacheWithoutTouchingSnapshotSeam() {
PaimonTableHandle h = new PaimonTableHandle(
"db1", "t1", Collections.emptyList(), Collections.emptyList());
h.setPaimonTable(table);
- ConnectorPartitionViewCache> cache = partitionViewCache();
+ ConnectorMetadataCache> cache = partitionViewCache();
PaimonConnectorMetadata md = metadataWithCache(ops, cache);
List result = md.listPartitions(null, h, Optional.empty());
@@ -259,4 +260,99 @@ public void unpartitionedHandleBypassesCacheWithoutTouchingSnapshotSeam() {
Assertions.assertTrue(result.isEmpty());
Assertions.assertTrue(ops.log.isEmpty(), "an unpartitioned handle must not touch any remote seam");
}
+
+ @Test
+ public void listPartitionNamesCachesAcrossQueries() {
+ // WHY (PA-1): listPartitionNames now routes through the SAME partitionViewCache as listPartitions
+ // (SHOW PARTITIONS re-rendered the full list on every call before). Two calls on the same latest
+ // snapshot must share one entry: the seam runs once and both calls return identical names.
+ // MUTATION: listPartitionNames calling collectPartitions directly (the pre-fix code) -> loadCount 2 -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Arrays.asList(partition("cn"), partition("us"));
+ ConnectorMetadataCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ List first = md.listPartitionNames(null, h);
+ List second = md.listPartitionNames(null, h);
+
+ Assertions.assertEquals(Arrays.asList("region=cn", "region=us"), first);
+ Assertions.assertEquals(first, second, "the cached list drives identical names");
+ Assertions.assertEquals(1, loadCount(ops), "listPartitionNames must hit the shared cache (enumerate once)");
+ }
+
+ @Test
+ public void listPartitionValuesCachesAcrossQueries() {
+ // WHY (PA-1): same as above for the partition_values() TVF path -- it re-rendered on every call before.
+ // MUTATION: listPartitionValues calling collectPartitions directly -> loadCount 2 -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Arrays.asList(partition("cn"), partition("us"));
+ ConnectorMetadataCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ List cols = Collections.singletonList("region");
+ List> first = md.listPartitionValues(null, h, cols);
+ List> second = md.listPartitionValues(null, h, cols);
+
+ Assertions.assertEquals(
+ Arrays.asList(Collections.singletonList("cn"), Collections.singletonList("us")), first);
+ Assertions.assertEquals(first, second, "the cached list drives identical values");
+ Assertions.assertEquals(1, loadCount(ops), "listPartitionValues must hit the shared cache (enumerate once)");
+ }
+
+ @Test
+ public void allThreeHooksShareOneCacheEntry() {
+ // WHY (PA-1): the three enumeration hooks share ONE (db,table,snapshotId) entry -- listPartitions
+ // populates it, listPartitionNames and listPartitionValues then derive from the SAME cached list
+ // without re-enumerating, and the derived outputs stay byte-consistent with listPartitions' rendered
+ // list. MUTATION: any hook bypassing the shared cachedPartitions -> loadCount > 1 -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Arrays.asList(partition("cn"), partition("us"));
+ ConnectorMetadataCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ List full = md.listPartitions(null, h, Optional.empty());
+ List namesOut = md.listPartitionNames(null, h);
+ List> valuesOut = md.listPartitionValues(null, h, Collections.singletonList("region"));
+
+ Assertions.assertEquals(1, loadCount(ops), "all three hooks must share one cache entry (enumerate once)");
+ Assertions.assertEquals(names(full), namesOut, "listPartitionNames equals the names of listPartitions' list");
+ Assertions.assertEquals(
+ full.stream().map(p -> Collections.singletonList(p.getPartitionValues().get("region")))
+ .collect(Collectors.toList()),
+ valuesOut, "listPartitionValues equals the values derived from listPartitions' list");
+ }
+
+ @Test
+ public void unpartitionedNamesAndValuesBypassCacheWithoutTouchingSnapshotSeam() {
+ // WHY (PA-1): the "no seam call for unpartitioned" contract must hold for the new routing of
+ // listPartitionNames and listPartitionValues too -- cachedPartitions short-circuits before building a
+ // key, so neither latestSnapshotId nor listPartitions is called. MUTATION: routing through the cache
+ // before the emptiness check -> "latestSnapshotId" appears in ops.log -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = new FakePaimonTable(
+ "t1", RowType.builder().field("id", DataTypes.INT()).build(),
+ Collections.emptyList(), Collections.emptyList());
+ ops.table = table;
+ PaimonTableHandle h = new PaimonTableHandle(
+ "db1", "t1", Collections.emptyList(), Collections.emptyList());
+ h.setPaimonTable(table);
+ ConnectorMetadataCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+
+ Assertions.assertTrue(md.listPartitionNames(null, h).isEmpty());
+ Assertions.assertTrue(md.listPartitionValues(null, h, Collections.singletonList("region")).isEmpty());
+ Assertions.assertTrue(ops.log.isEmpty(), "unpartitioned names/values must not touch any remote seam");
+ }
}
diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml
index 065e21387f2d66..254712404947ce 100644
--- a/fe/fe-connector/pom.xml
+++ b/fe/fe-connector/pom.xml
@@ -100,25 +100,6 @@ under the License.
-
-
- check-authz-cache-sharding
- validate
-
- exec
-
-
- ${project.basedir}/../../tools/check-authz-cache-sharding.sh
-
- ${project.basedir}
-
-
-
diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java
index b7d868e4a3f43e..19a9b529b16129 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java
@@ -24,7 +24,13 @@
import org.apache.doris.connector.api.handle.ConnectorTransaction;
import org.apache.doris.datasource.plugin.CatalogStatementTransaction;
import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.trees.plans.commands.ExecuteCommand;
+import org.apache.doris.nereids.trees.plans.commands.PrepareCommand;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.PreparedStatementContext;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.qe.StmtExecutor;
import org.apache.doris.transaction.PluginDrivenTransactionManager;
import org.junit.jupiter.api.Assertions;
@@ -38,8 +44,10 @@
/**
* Tests for the per-statement {@link ConnectorStatementScope}: the {@link ConnectorStatementScope#NONE} no-op,
- * the memoizing {@link ConnectorStatementScopeImpl}, and the {@link StatementContext} hosting + per-execution
- * reset a reused prepared statement relies on.
+ * the memoizing {@link ConnectorStatementScopeImpl}, the {@link StatementContext} hosting + per-execution
+ * reset a reused prepared statement relies on, and that {@link ExecuteCommand} actually invokes that reset on
+ * every execution (external/connector tables are planned through the reused prepared context, so a missing
+ * reset would leak one execution's loaded table into the next — see {@code executeCommandResetsConnectorScope*}).
*/
public class ConnectorStatementScopeTest {
@@ -175,6 +183,49 @@ public void resetClosesTheDroppedScopeBeforeStartingFresh() {
"reset drops the scope so the next execution/attempt starts fresh");
}
+ @Test
+ public void executeCommandResetsConnectorScopePerExecution() throws Exception {
+ // WIRING test: the reset above is only load-bearing if ExecuteCommand.run() actually calls it. A prepared
+ // statement reuses ONE StatementContext across every EXECUTE, and an EXTERNAL/connector table is planned
+ // through that reused context each execution (external tables never take the OLAP short-circuit fast path;
+ // they always fall to the normal executor.execute() planner, which re-resolves the table per execution).
+ // So run() must drop the connector per-statement scope at the top of every execution, or a prior execution's
+ // memoized (loaded) table leaks into the next. The tests above cover only the reset PRIMITIVE; this covers
+ // that the command invokes it. MUTATION: delete `statementContext.resetConnectorStatementScope()` from
+ // ExecuteCommand.run() -> the seeded value survives -> the assertNotSame below flips -> red.
+ StatementContext statementContext = new StatementContext();
+ // Seed a value the way a first execution's connector planning would (one loaded table the statement shares).
+ ConnectorStatementScope firstScope = statementContext.getOrCreateConnectorStatementScope();
+ Object memoizedTable = firstScope.computeIfAbsent("table:1", Object::new);
+
+ // A prepared statement wrapping that reused StatementContext, with a plain (non-cache, non-insert) plan.
+ LogicalPlan plan = Mockito.mock(LogicalPlan.class);
+ PrepareCommand prepareCommand = Mockito.mock(PrepareCommand.class);
+ Mockito.when(prepareCommand.getLogicalPlan()).thenReturn(plan);
+
+ ConnectContext ctx = Mockito.mock(ConnectContext.class);
+ PreparedStatementContext preparedStmtCtx = new PreparedStatementContext(
+ prepareCommand, ctx, statementContext, "SELECT * FROM ext_catalog.db.t WHERE id = ?");
+ Mockito.when(ctx.getPreparedStementContext("s")).thenReturn(preparedStmtCtx);
+ // Force the plain-SELECT path: skip the OLAP group-commit fast path so run() falls to executor.execute().
+ SessionVariable sessionVariable = new SessionVariable();
+ sessionVariable.enableGroupCommitFullPrepare = false;
+ Mockito.when(ctx.getSessionVariable()).thenReturn(sessionVariable);
+ Mockito.when(ctx.getStatementContext()).thenReturn(statementContext);
+
+ // A no-op executor: we pin the reset wiring, not the planner. execute() is a mock no-op.
+ StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+ Mockito.when(executor.getContext()).thenReturn(ctx);
+
+ new ExecuteCommand("s", prepareCommand, statementContext).run(ctx, executor);
+
+ ConnectorStatementScope secondScope = statementContext.getOrCreateConnectorStatementScope();
+ Assertions.assertNotSame(firstScope, secondScope,
+ "ExecuteCommand drops the reused context's connector scope so the next execution starts fresh");
+ Assertions.assertNotSame(memoizedTable, secondScope.computeIfAbsent("table:1", Object::new),
+ "a prior execution's memoized connector table must not leak into the next EXECUTE");
+ }
+
@Test
public void closeClosesScopeWhenReturningResultLocally() {
// A statement that never reaches the query-finish callback (external DDL / SHOW via Command.run) is
diff --git a/plan-doc/HANDOFF.md b/plan-doc/HANDOFF.md
index fac08121d87a6f..648ce10c346e9d 100644
--- a/plan-doc/HANDOFF.md
+++ b/plan-doc/HANDOFF.md
@@ -5,7 +5,71 @@
---
-# 🆕 下一个 session = **重跑 CI 验证本轮 3 个修复**
+# 🆕🆕 最新一轮(2026-07-25):CI **1005291** 的 iceberg 大小写列名回归 —— 已修待 CI 验
+
+> 任务 = TeamCity `Doris_External_Regression` **#1005291**(PR **66028** @ `7ff51a106f0`)中
+> `external_table_p0/iceberg/test_iceberg_nested_schema_evolution_spark_doris_interop.groovy:273`。
+> 该 build 整体 SUCCESS(603 passed),这条是 **muted** 的失败。
+
+## 定性:本分支独有的**真回归**,不是 flaky、不是集群故障
+
+同一用例在 pull/66011、65847、66006、65851、65126 上均 SUCCESS;另外两个 FAILURE(66007、65851)
+是完全不同的原因(`can not cast from origin type STRUCT<...>`、`No backend available / not alive`),**与本问题无关**。
+本地 HEAD 与 PR 66028 head 一致,`fe/fe-core/.../datasource/iceberg/` 已整体删除 ⇒ 走的一定是连接器路径。
+
+## 根因:#65329 的**平坦(top-level)臂**只移植了一半
+
+上游 `IcebergMetadataOps`(`git show 70a82532325:fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java`)
+有 **5 处** `validateNoCaseInsensitiveSiblingCollision` 调用:flat ADD(:885)、nested ADD(:918)、
+ADD COLUMNS 批量(:946→helper :1428)、flat RENAME(:1005,且前置 :1000 用 `resolveColumnPath` 大小写不敏感解析**源列名**)、nested RENAME(:1028)。
+移植只带进了**两处 nested**(`IcebergNestedColumnEvolution:71/99`)。
+
+顶层 DDL **根本到不了**那个类:fe-core `PluginDrivenExternalCatalog:908-913/949-951` 对 `!columnPath.isNested()` 直接短路回平坦 SPI,
+`IcebergConnectorMetadata.addNestedColumn/renameNestedColumn` 再短路一次;终点
+`CatalogBackedIcebergCatalogOps.addColumn/addColumns/renameColumn` 直接 `UpdateSchema` 零校验。
+Iceberg 自己也挡不住 —— `SchemaUpdate` 构造器 `caseSensitive = true`,`findField("id")` 看不见 `Id`。
+⇒ 该校验器里 `parentPath.isEmpty()` 那条顶层分支一直是**死代码**,正是漏掉调用点的信号。
+
+## ✅ 已交付:commit `f1104a6880d`
+
+三个平坦入口接上 nested 臂已有的校验器;`validateNoCaseInsensitiveSiblingCollision` 放宽为包内可见,
+新增 `validateNoCaseInsensitiveTopLevelCollisions`(批量 + 请求内去重)与 `renameTopLevelColumn`
+(大小写不敏感解析源名 + 冲突校验 + 复用 `applyRenameColumn` 的 identifier-field 修复)。**未碰 fe-core**。
+
+验证:先 RED(7 个新用例在修前失败,症状与 CI 完全一致:ADD 是 `nothing was thrown`,RENAME 是
+`Cannot rename missing column: label`)→ 后 GREEN;模块 **1145/1145**(5 个既有 live-connectivity skip)、
+**全 reactor `test-compile` BUILD SUCCESS**、checkstyle 0。5 路对抗复审 + 3 skeptic 表决:**0 条成立**。
+该 suite 内**唯一**的顶层列 DDL 就是 274–289 行,其余全是 dotted 嵌套路径(未受影响);
+289 行 `RENAME COLUMN label TO label` 会把列名变小写,但 `mixedCaseTable` 最后一次被引用就在 296 行,无下游影响。
+
+## ⏭ 下一个 session
+
+1. **重跑 CI 是唯一真闸门**。预期 273/277/281/285/289 五条全过(后四条此前从未被执行到)。
+2. **同族平坦臂缺口,本轮故意未打包**(复审提出、经表决判定为既有问题且超出本次范围,非本次引入):
+ - flat `dropColumn`(`IcebergCatalogOps` 内)仍按大小写敏感解析 ⇒ 对 Spark 混合大小写表 `DROP COLUMN label` 会失败(上游 :961 用 `resolveColumnPath`);
+ - flat `modifyColumn` 用大小写敏感 `Schema.findField` ⇒ `MODIFY COLUMN id` 报 "Column id does not exist";
+ - flat `applyPosition` 的 `AFTER [` 参照列未做大小写不敏感解析;
+ - `reorderColumns` 既不规范化大小写也不查重复。
+ 以上**均未被本 suite 触发**(该 suite 的 DROP/MODIFY 全是 dotted 嵌套路径),故不阻塞本次 CI。
+ 另:row-lineage mutation guard 的缺失是**本分支既有的、有文档的有意偏离**(见 `IcebergConnectorMetadata:1282`),不是缺口。
+3. 复审旁获(未验证、与本次无关):有 agent 声称 hive 网关未把 5 个 ColumnPath 列操作委派给 iceberg 兄弟,
+ 导致 iceberg-on-HMS 的 `MODIFY COLUMN COMMENT` 恒不可用。**未经我独立核实**,要动前先自己 trace。
+
+## 🧰 本轮新增构建坑(补充下面第 1 条)
+
+**`-Dmaven.build.cache.enabled=false` 会连带打破 shade 依赖链**:`fe-connector-hms-hive-shade` 的 shade 插件绑在
+`package` 阶段,而 `test`/`compile` 生命周期到不了 `package` ⇒ 关缓存后 `fe-connector-hms` 编译报
+`package org.apache.hadoop.hive.metastore.api does not exist`(**与被改代码无关**,该 reactor 里根本没有 iceberg 模块)。
+正确姿势:先 `-pl <目标模块> -am install -DskipTests -Dmaven.build.cache.enabled=false`(跑到 package 产出 shade jar),
+再 `-pl <目标模块> test -Dmaven.build.cache.enabled=false`(**不带 `-am`**)。
+
+**另注**:`fe-connector-api` 等上游模块的**已安装 jar 常落后于工作区**,只 `-pl <模块>` 不带 `-am` 会撞
+"no suitable method found" / "cannot find symbol" 之类的**假编译错**(本轮撞了两次:`ConnectorType.structOf` 5 参、
+`isChildCommentSpecified`)—— 那不是代码坏了,是 jar 陈旧。
+
+---
+
+# 🕗 上一轮 = **重跑 CI 验证 3 个修复**(997422)
> **本轮任务** = TeamCity `Doris_External_Regression` **#997422**(PR 65474 @ `6a450c9fa79`)
> **10 failed + 2 muted**(occurrence 口径 12)。
diff --git a/plan-doc/connector-cache-unification/00-research-report.md b/plan-doc/connector-cache-unification/00-research-report.md
new file mode 100644
index 00000000000000..4e7c4ea6a8a92f
--- /dev/null
+++ b/plan-doc/connector-cache-unification/00-research-report.md
@@ -0,0 +1,168 @@
+Spot-checks confirm the spine: `SPI_READY_TYPES` declared at `CatalogFactory.java:56`, the 7-type `ImmutableSet` literal at `:57`; `PluginDrivenMetadata.get` at `:53` with the identity pin at `:63-69` and the funnel key `"metadata:"+catalogId` at `:70`; both gates exist, `check-authz-cache-sharding.sh` hard-wired to `IcebergConnector.java` (`TARGET_REL`, `:57`). Now the report.
+
+---
+
+# 调研报告:`0b4f72582e7` 统一 iceberg 缓存 + per-statement metadata funnel 框架能否推广到其余连接器
+
+> 基线:`branch-catalog-spi` @ HEAD。参考 commit `0b4f72582e7`。本报告的每条论断均挂在 HEAD 代码的 `file:line` 上;连接器逐条结论来自已经过对抗式复核(adversarial verify)的 per-connector 审计。凡输入之间有分歧或低置信处,本报告显式标注。
+
+---
+
+## 1. 结论先行 (Executive Summary)
+
+一句话回答 owner:**这个框架有三层,其中 Layer-2(per-statement `ConnectorMetadata` funnel)以及它所依托的共享底座 `fe-connector-cache` 已经是连接器无关(connector-agnostic)、对全部 7 个 `SPI_READY_TYPES` 生效的通用设施——"统一(goal-1)"这一半其实已经做完;真正剩下的、需要逐连接器补齐的,是 Layer-1 那种"连接器自己是否 memoize 重活"的具体填充,而这只在极少数连接器上是真缺口。**
+
+- **Goal-1(统一缓存框架)最重要的一条建议:不要造"一个连接器无关的元数据大缓存"。** Trino 的实证(INPUT B §3)证明正确的统一粒度是**工具箱(toolkit)**而非缓存本身——因为被缓存对象的类型、失效语义、授权语义天生按连接器不同。Doris 已经站在这条 Trino-correct 路径上:`fe-connector-cache`(`MetaCacheEntry`/`CacheSpec`/`CacheFactory`/`ConnectorPartitionViewCache`)就是 Doris 版的 `io.trino.cache`,并已被 hive/hms/iceberg/maxcompute/paimon 采用。统一工作 = 把**尚未采用工具箱且确有热路径缺口**的连接器接上去(唯一强需求是 **hudi**),外加把 3 个"格式中立"的 iceberg 缓存(table-handle / comment / file-format)在**出现第 2 个消费者时**上收为 `ConnectorXViewCache` 泛型封装——而不是投机式地提前抽象(遵循 Rule 2)。
+
+- **Goal-2(热路径性能 + 一致性)最重要的一条建议:唯一 loop-amplified(iceberg-式)的 P1 缺口是 hudi;maxcompute、es 则是 constant-factor(2–4x)的 P1 收尾。** hudi 是所有 SPI 连接器里缓存最薄的一个:零连接器侧 cross-query 元数据缓存、零 per-statement memo,甚至没有像 hive 那样包 `CachingHmsClient`(用的是裸 `ThriftHmsClient`),导致 `HoodieTableMetaClient` 每个 planning pass 重建约 5–6 次、schema 重解析约 4 次(INPUT D `HD-P01/HD-P02/HD-P03`,P1)。这是与"翻闸前 iceberg 那批被删缓存"最像的问题。其余连接器要么已经很好地缓存(hive/paimon/maxcompute-分区),要么结构上不需要(jdbc/es/trino 是廉价透传或委托嵌入式引擎缓存),只剩若干常数倍(2–4x)的 P1/P2 收尾。
+
+**净结论:引擎接缝(Layer-2 + 其 gate + `fe-connector-cache` + 泛型分区视图缓存 + 通用 `shouldBypass*` 谓词)已通用且承重;iceberg 特有的是"填充物"。推广工作是有选择地填充,不是重新造框架——重点投在 hudi,其次是 maxcompute / es 的少量常数倍收尾。**
+
+---
+
+## 2. 框架解剖:三层 + 已有共享底座
+
+参考 commit 引入的框架分三层(INPUT A 在 HEAD 上核实并锐化):
+
+**Layer-2 —— 连接器无关的 per-statement metadata funnel(已通用、已承重)。**
+这是 Doris 版的 Trino per-transaction `CatalogMetadata`(INPUT B §1):保证 *每个 (statement, catalog) 只有一个 `ConnectorMetadata` 实例*,让一条语句里所有 read/scan/DDL/MVCC/write 解析器共享同一个 metadata。核心件:
+- funnel 本体 `PluginDrivenMetadata.get(session, connector)`(`PluginDrivenMetadata.java:53`),按 `"metadata:"+catalogId` 记忆(`:70`),并有 fail-loud 身份钉(`:63-69`),把跨用户凭证复用变成硬错误。这是 fe-core 中**唯一**被允许调 `Connector#getMetadata` 的地方。
+- SPI 中立接缝 `ConnectorStatementScope`(默认 `NONE` no-op),物理归宿 `StatementContext.connectorStatementScope`,写事务共持有者 `CatalogStatementTransaction`,两遍有序 teardown `ConnectorStatementScopeImpl.closeAll`。
+- **关键事实:Layer-2 对全部 `SPI_READY_TYPES = {jdbc, es, trino-connector, max_compute, paimon, iceberg, hms}`(`CatalogFactory.java:56-57`)一体适用,scope 捕获无任何连接器类型分支;hudi 作为 hms 网关下的 sibling 也经 `memoizedSiblingMetadata` 骑同一 scope。** 由 `tools/check-fecore-metadata-funnel.sh` 全构建强制(当前 0 个 exempt marker,写臂已全部入 funnel)。fe-core 中经 funnel 的 call sites 约 **58–59 处**(计法差异,不影响结论)。
+
+**Layer-1 —— 6 个 iceberg 连接器侧 cross-query 缓存(一个*可复制的模式*,不是通用代码)。**
+`IcebergTableCache / IcebergPartitionCache / IcebergFormatCache / IcebergCommentCache / IcebergManifestCache / IcebergLatestSnapshotCache`,全部建在共享 `MetaCacheEntry` 之上。其中 **table-handle / comment / file-format** 在意图上格式中立(每个连接器都要 load 表对象 / 取注释 / 推断格式),只是 value 类型 iceberg 化;而 **PARTITIONS 元数据表扫描 / manifest / snapshot+schemaId 原子钉** 是真正 iceberg-格式特有。
+
+**Layer-3 —— `iceberg.rest.session=user` 下的授权缓存隔离。** 名字为 key 的缓存会把"可 LIST 不可 LOAD"用户的元数据泄漏给他;iceberg 在 `isUserSessionEnabled()` 下把这些缓存置 null,fe-core 侧加通用谓词 `shouldBypass{Schema,TableName,DbName}Cache`。由 `tools/check-authz-cache-sharding.sh` 强制,但**该 gate 硬编码只盯 `IcebergConnector.java`**(核实:`TARGET_REL`,`:57`)。
+
+**已有共享底座 `fe-connector-cache`(统一的天然归宿,且泛化已在进行)。** `MetaCacheEntry`(Caffeine-free 的统一 entry API)、`CacheSpec`(`meta.cache...*` 配置)、`CacheFactory`,以及一个**已经写好的 `IcebergPartitionCache` 泛型版** `ConnectorPartitionViewCache`/`PartitionViewCacheKey`。采用现状:`MetaCacheEntry` 被 hive/hms/iceberg/maxcompute/paimon 用;`ConnectorPartitionViewCache` 至少被 hive、paimon 实例化(INPUT D 确认;iceberg 使用其泛型形态见 INPUT A)。**一处 drift 需修:`ConnectorPartitionViewCache` 类 javadoc 写"NO consumers yet"是 HEAD 陈旧——已有 ≥2 个确认消费者(hive、paimon)(Rule 7/12,doc-only)。**
+
+**这一层的含义:"统一"不是从零造框架,而是:(a) 已通用的 Layer-2 无需再做;(b) 剩余缺口 = Layer-1 式 per-connector memoization + 把确有缺口的连接器接上已有工具箱。**
+
+---
+
+## 3. 横向矩阵 (Cross-Connector Matrix)
+
+来源 INPUT D(已对抗式复核;`verifyVerdict` 见括注)。iceberg 为参考实现,已完成。
+
+| 连接器 | live? | funnel 记忆已加载表? | 现有缓存(要点) | 头号热路径缺口 | authz / session=user 相关? | 优先级 |
+|---|---|---|---|---|---|---|
+| **iceberg**(参考) | live | **是**(唯一真正 one-load-per-statement:`IcebergStatementScope.sharedTable` + `IcebergTableCache`) | 6 缓存 + per-scan/per-file hoist | —(已修) | **是**(唯一声明 `SUPPORTS_USER_SESSION`) | 参考/已完成 |
+| **hive**(=hms) | live | 部分(实例无 memo;靠 cross-query `CachingHmsClient` 折叠) | `CachingHmsClient`(表/分区名/分区/列统计 4 缓存)+ `HiveFileListingCache` + `ConnectorPartitionViewCache` + sibling memo + 写事务快照 | `HMS-H4` ACID `getAcidState` 未缓存(severity none);`HMS-H6` 罕见 TTL/eviction 二次 RPC(P2) | **否**(单一 catalog 身份;RBAC 独立把关) | **LOW(仅 doc 修)** |
+| **hudi**(hms sibling,**不在** `SPI_READY_TYPES`) | live | **否**(对象每语句一个,但内部零 memo;scan provider 另建 metaClient) | **零 cross-query 缓存;裸 `ThriftHmsClient`(连 `CachingHmsClient` 都没包)** | `HD-P01` metaClient 5–6x/pass、`HD-P02` schema 4x、`HD-P03` 分区扫描(均 **P1**);`HD-P04` 裸 HMS(P2);`HD-P05` hoist(P2) | 否(无 `getCapabilities` override → 无 `SUPPORTS_USER_SESSION`) | **HIGH / P1(旗舰目标)** |
+| **maxcompute** | live | 否(fat-handle 携带 ODPS `Table`,不跨 handle 共享) | `MaxComputePartitionCache`(600s,建在工具箱)+ fe-core schema 缓存;split 枚举干净 | `MC-1` 每次 handle 解析冗余 `tables().exists()` 远程探测,k×/语句(**P1**);`MC-2` lazy `Table` reload(P2) | 否(catalog 静态 AK/SK,单身份) | **MEDIUM / P1(单个小修)** |
+| **es** | live | 否(schema/scan-state 均不 memo) | 仅 per-catalog REST client + fe-core schema/rowcount 缓存;连接器侧零 scan 元数据缓存 | `ES-F1` `fetchMetadataState` 2x/查询(shards+nodes,**P1**);`ES-F2` mapping 重取 2x(**P1**);`ES-F3` `getColumnHandles` mapping 2x(P2) | 否(单一 catalog user/password) | **MEDIUM / P1(per-scan hoist)** |
+| **jdbc** | live | 否(metadata 构造近零成本) | HikariCP 连接池(per-catalog 单例)+ driver classloader map + fe-core schema/rowcount/name 缓存 | `HP-1` scan 期 `getColumnHandles` 冗余远程取列(P2);`HP-2` 写路径 `buildInsertSql` 新建实例绕开 funnel(P2) | 否(固定 catalog 凭证) | **LOW / P2** |
+| **trino-connector** | live(bridge) | 否(委托嵌入式 Trino 连接器自有缓存) | Trino 引导单例 + 每 catalog 一个嵌入式 Trino `Connector`(自带 `CachingHiveMetastore` 等)+ fe-core 缓存 | `TRINO-H1` cold 3x `getTableHandle`(P2,`memoizedAlready=true`);`TRINO-H2` 2N `getColumnMetadata`(P2);`TRINO-H3` 2x `applyFilter`(P2) | 否(静态单身份 `Identity.ofUser("user")`) | **LOW / P2(L1 缓存被明确反指)** |
+| **paimon** | live | 部分(fat-handle + paimon SDK `CachingCatalog`) | `PaimonLatestSnapshotCache` + `PaimonSchemaAtMemo` + `partitionViewCache` + SDK `CachingCatalog`(tableCache/partitionCache/manifestCache) | `PA-1` `listPartitionNames/Values` 绕过 `partitionViewCache`(P2,仅 CPU 重渲染) | 否(catalog-创建期认证;REST vended token 是表级、非用户级) | **LOW / P2(一致性小清理)** |
+
+---
+
+## 4. Goal-1:统一缓存框架的建议
+
+**总纲:统一在工具箱层,不在缓存层(Trino-correct)。** INPUT B §3 用 Trino 源码证明:Trino 没有"一个统一连接器缓存",只有共享的 `io.trino.cache` 构造/淘汰安全/统计机具,各连接器在其上各建各的缓存。Doris 已如此。因此 goal-1 拆成四件可落地的事:
+
+**(1) Layer-2 已通用——不动。** funnel + 其 gate + `NONE` read-through + `CatalogStatementTransaction` + `PluginDrivenScanNode` provider/metadata memo,都已连接器无关且被强制。这半个"统一"已完成。
+
+**(2) 真正的"统一"缺口不是缓存,是 per-statement 表解析去重(INPUT B §3 第 2 点)。** Trino 天然免费拿到"每事务每表 load 一次",因为 metadata 实例就是事务级 memo 家;Doris funnel 只保证"每语句一个 metadata 实例",是否折叠重复 load 因连接器而异。**但复核(INPUT C/D)显示这个"统一 helper"实际只对 iceberg(已有)和 hudi(metaClient 重建)是刚需**;maxcompute 因 metadata 已是每语句一个,只需在实例内加一个 `Map<(db,table),Handle>`(比 iceberg 的 scope-keyed memo 更简单);paimon/jdbc/es/trino 不构成远程放大。**建议:提供一个共享的"经 statement scope 解析表"helper 作为可选接缝,但只在 iceberg + hudi 两个真消费者上启用**;不要为 4 个不需要的连接器强推(Rule 2)。
+
+**(3) 可复用原语 vs 必须留在 iceberg 模块的格式特有物——明确区分:**
+
+*可上收为通用原语(有 ≥2 消费者或即将有):*
+- **`ConnectorPartitionViewCache`(分区视图缓存)——已经是通用原语,已被 hive、paimon 实例化(iceberg 用其泛型形态)。** 建议 hudi 采用它解 `HD-P03`;maxcompute 现用自建 `MaxComputePartitionCache`(同样建在 `MetaCacheEntry`)可保留或迁移,非必须。
+- **`CachingHmsClient`(metastore-client 缓存包装)——已被 hive/hms 共享。** hudi 应直接采用(INPUT D `HD-P04`:一行 parity,立即修 hudi 的裸 HMS 问题)。这是最干净的复用收益。
+- **格式中立的 3 个 iceberg 缓存(table-handle / comment / file-format)**:结构上都是 `new MetaCacheEntry<>(name, null, spec, commonPool, false, true, 0L, true)`,可各自折叠成 `ConnectorXViewCache` 泛型封装。**但复核显示其余连接器几乎都不需要连接器侧 table 缓存**(paimon 有 SDK 缓存、hive 有 `CachingHmsClient`、trino 委托嵌入式、jdbc/es 廉价)。唯一新消费者是 hudi(需要 cross-query metaClient/table 缓存)。**因此建议:泛型 table/handle 缓存在 iceberg+hudi 双消费者出现时再上收;在此之前保持单模块,不投机抽象。**
+
+*必须留在 iceberg 模块(真正格式特有,不要泛化):*
+- `IcebergManifestCache`(manifest 文件)、`IcebergPartitionCache`(PARTITIONS 元数据表 + transform 分区)、`IcebergLatestSnapshotCache`(snapshot id + schema id 原子钉)。hudi 的对应物(metaClient / timeline / Hudi 分区元数据表)语义不同,应在 hudi 模块各自实现,只共享底层 `MetaCacheEntry`/`CacheSpec`。
+
+**(4) doc-only 统一债:** 修 `ConnectorPartitionViewCache` 的"no consumers yet"陈旧 javadoc;修 hive/hudi/maxcompute 中"Dormant / hms is not in SPI_READY_TYPES / dormant until hms enters SPI_READY_TYPES / today getTableHandle is never called"等一批陈旧注释(hms 已 live,#65473)。
+
+---
+
+## 5. Goal-2:热路径性能 + 一致性路线图
+
+**重要前提:其余连接器里没有一个存在 iceberg 翻闸前那种 P0 loop-amplified 远程回归——那批 P0 就是 iceberg 本身,已修。** 下面按证据 id 与预期倍数削减排序。
+
+### 5.1 查询路径 (query path)
+
+**P1(旗舰):hudi 全套 memo。** 目标削减:
+- `HD-P01`(`HudiScanPlanProvider.java:148` 等):`HoodieTableMetaClient` 每 pass 重建 **~5–6x → 1x**(最小无条件 ~3–4x,含条件站点达 5–6x;复核 `verifyVerdict=ADJUSTED`,上界成立)。做法:per-statement 解析后的 metaClient+schema memo,由 metadata 与 scan provider 共享同一 `ConnectorStatementScope`。
+- `HD-P02`(`HudiConnectorMetadata.java:797` 等):Avro+InternalSchema 重解析 **~4x → 1x**。
+- `HD-P03`(`HudiScanPlanProvider.java:734`):分区元数据表扫描——复核修正:**过滤查询已去重到 ~1 次**(`HudiConnectorMetadata.java:288-291` 注释 + `resolvePartitions` 短路 `:635-638`),"3x/pass" 是上界,真正放大发生在 fe-core 独立多次调 `listPartitions/Names/Values` 或无过滤扫描,以及一次 MTMV refresh 的 4–6 次重枚举。做法:`(table,instant)`-keyed 分区缓存(用 `ConnectorPartitionViewCache`)。
+
+**P1:maxcompute `MC-1`。** `getTableHandle` 每次解析都发冗余 ODPS `tables().exists()` 远程探测(`MaxComputeConnectorMetadata.java:118-130`);funnel 只 memo metadata 不 memo handle,故一条语句 k 个独立 handle 解析站点(复核确认 **10 个直接 `resolveConnectorTableHandle` 站点** + 写能力探测)各付一次 RPC。做法:在**已经是每语句一个**的 metadata 实例内加 `Map<(db,table),Handle>`,并对已解析读路径去掉多余存在性探测。削减:**k×/语句 → 1×/语句**,顺带收 `MC-2` 的 lazy `Table` reload。低风险高杠杆。
+
+**P1:es `ES-F1`/`ES-F2`。** `fetchMetadataState`(shards+nodes+field-context)每查询被调 2 次(`EsScanPlanProvider.java:99,169`),mapping 又被重取(`EsMetadataFetcher.java:57-58`)。做法:provider 已是每 scan-node 单例,加一个按 `(index,columnNames)` 的字段 memo,把 **2x→1x**,零 staleness 风险(同语句)。**重要修正(复核 `verifyVerdict=ADJUSTED`):`ES-F2` 不是"零新增缓存直接复用 schema"——fe-core `ExternalSchemaCache` 只存解析后的列,`EsConnectorMetadata.getTableSchema` 丢弃了原始 mapping(`:90-93` 传空 properties map),而 `resolveFieldContext` 需要原始 mapping。消除它要么让 `ConnectorTableSchema` 携带 field-context,要么加一个 per-statement/cross-query mapping 缓存。** `ES-F3` getColumnHandles mapping 2x 是 P2(在每语句一个的 metadata 上 memo schema 即可)。**注意:ES shard routing 必须留在 per-statement,不能 cross-query 缓存(ES rebalance/refresh 模型)。**
+
+**P2 收尾:** hudi `HD-P04`(裸 HMS→包 `CachingHmsClient`)、`HD-P05`(per-scan hoist metaClient/schema/storage-config/uri-normalizer);paimon `PA-1`(`listPartitionNames/Values` 路由进 `partitionViewCache`,仅省 CPU 重渲染,底层远程已被 SDK partitionCache 挡);jdbc `HP-1`;es `ES-F3`;trino `H1/H2/H3`(见 §7,L1 缓存被反指,只做 CPU 清理)。
+
+### 5.2 写路径一致性 (write path,read+write 共享一 metadata/一 txn)
+
+**机制已通用且被强制:** fe-core 的写路径 `getMetadata` 接缝都经同一 funnel,身份钉护住跨用户凭证复用;对**有写事务的连接器(hive / maxcompute / iceberg)**,读写共享同一 memoized `ConnectorMetadata`,写事务由 `CatalogStatementTransaction` 在同一 scope 上共持有,两遍有序 teardown(先 finalize txn 再 close metadata)。当前 gate 0 exempt marker。
+> 例外(不构成一致性风险):jdbc 的 `buildInsertSql` 在连接器内部 new 一个**非 funnel** 的 `JdbcConnectorMetadata`(INPUT D `HP-2`,P2),故 jdbc 的读与写不共享同一 memoized metadata;但 jdbc 写是 BE 逐行 auto-commit(`NoOpConnectorTransaction`),无读写快照一致性需求,因此正确性中立。
+
+**哪些连接器有写路径、需要写事务共持有者:**
+- **已有且已接线:hive**(`HiveConnectorTransaction`,捕获 begin-time 表快照供 reject-guard 与 sink)、**maxcompute**(`MaxComputeConnectorTransaction`,write session + block 分配按 txn_id;非-MVCC,无需读写快照共享)、以及 **iceberg**(`CatalogStatementTransaction` 参考)。
+- **不需要:** paimon(写**未**迁移,`getCapabilities` 不声明写,`PaimonConnector.java:318`)、jdbc(BE 逐行 auto-commit,`NoOpConnectorTransaction`)、es/trino/hudi(只读,`beginTransaction` throws 或无 override)。
+
+**写路径一致性缺口(相对 Trino,INPUT B §2/§4):** Doris 对 hive 走"粗 REFRESH+TTL",不像 Trino 在事务内围绕写做失效——存在 TTL 有界的 read-your-write 窗口。建议:要么给 `CachingHmsClient` 加写路径失效,要么让写后读走 live(`NONE`/bypass)读。**优先级低(TTL 有界),且仅 hive 相关。**
+
+---
+
+## 6. 一致性与授权 (session=user) 的跨连接器影响
+
+**核心事实:在全部 8 个连接器中,只有 iceberg-REST 声明 `SUPPORTS_USER_SESSION` 并做 per-user 凭证 vending / delegated `loadTable`。** 其余 7 个连接器逐一核实(INPUT D)均为**单一 catalog 级身份**:
+
+- hive/hudi:catalog 级单一 Kerberos keytab principal 或 `context.executeAuthenticated`,无 per-user delegated 凭证、无 REST OIDC、无 per-identity metastore。hudi 的安全性(复核 `verifyVerdict=ADJUSTED`)不靠 hive 网关那个 iceberg-专用 guard(`HiveConnector.java:548-556` 文案是 iceberg-specific,`getOrCreateHudiSibling` 无等价 guard),而靠两条已核实事实:(a) hudi 无 `getCapabilities` override → 继承空默认,无 `SUPPORTS_USER_SESSION`;(b) hive 前门本身从不是 session=user。
+- maxcompute:catalog 静态 AK/SK。jdbc:固定 catalog user/password。es:单一 catalog user/password。trino:静态 `Identity.ofUser("user")`,底层用 catalog 属性里的静态凭证。paimon:catalog-创建期认证;REST vended token 是**表级、从共享 catalog session 取,非按 Doris 用户 key**(`PaimonScanPlanProvider.extractVendedToken`),故名字为 key 的缓存不会 list≠load 泄漏。
+
+**推论——直接影响 goal-1 的安全性:**
+
+1. **今天给这 7 个连接器加名字为 key 的 cross-query 缓存都是 authz-安全的**(所有用户看同一 catalog 身份视图,per-user 访问由 fe-core RBAC 独立把关)。因此推荐给 hudi 加的缓存(`CachingHmsClient` + 分区缓存)**当前无需 Layer-3 隔离**。
+2. **但这是"当前"的安全,不是"结构性"的安全。** 一旦任何连接器未来加入 per-user 凭证 vending(例如 hudi/hive 接入 REST-OIDC,或 paimon 把 vended token 变成 per-user),名字为 key 的缓存立刻变成 list≠load 泄漏面。而 `check-authz-cache-sharding.sh` **硬编码只盯 `IcebergConnector.java`**(核实 `:57`),不会保护第 2 个连接器。
+3. Layer-3 的**策略谓词** `shouldBypass{Schema,TableName,DbName}Cache` 已是连接器通用的 fe-core 代码(capability+credential gated),只是目前只有 iceberg-REST 触发它——这一半已经为未来准备好了;缺的是**缓存置 null 的连接器侧动作**与**gate 的通用化**。
+
+**建议:** 不要"统一"进一个元数据泄漏 bug。在给任何连接器加名字为 key 的 cross-query 缓存前,把"该连接器是否 vend per-user 凭证"作为准入检查项;并考虑把 `check-authz-cache-sharding.sh` 从 iceberg-only 改造成"扫描任意声明 `SUPPORTS_USER_SESSION` 的连接器"(见 §8 决策 3)。授权对比 Trino(INPUT B §2b):Trino 在 impersonation 下是**按身份分片缓存**(`LoadingCache`)而非禁用;Doris 目前是**禁用**——安全但更慢,长期方向是 Trino 式 per-identity keying,但那是更大的改动,当前"禁用"是正确的保守首版。
+
+---
+
+## 7. 风险与反对意见
+
+**(1) 对廉价/委托型连接器套 iceberg 重框架 = 投机,违反 Rule 2。**
+- **jdbc**:关系型透传,`planScan` 零远程 IO、恒一个 scan range,无 split/file/manifest/partition/snapshot 层。昂贵元数据(schema/columns、row-count、名录)已被 fe-core cross-query 缓存前置,连接池是 per-catalog 单例。加连接器侧 table 缓存无对象可缓存。
+- **es**:只读、非分区、单凭证、无 snapshot/manifest/stats/write。除 `ES-F1` 的 per-scan hoist 外,几乎每个 iceberg 件都不适用;且 **shard routing 绝不能 cross-query 缓存**(freshness/rebalance)。
+- **trino**:**主动反指(actively contraindicated)。** 它是 bridge,元数据缓存/失效/split 枚举全委托嵌入式 Trino 连接器。加 Doris 侧 table/handle 缓存会**双重缓存**并把 schema 跨外部 ALTER 冻结,重新引入 Trino 已解决的 stale-metadata bug 类;且 `TrinoTableHandle` 是事务派生、transient/不可序列化。只做 P2 CPU 清理(`H2` 去掉 2N 重取、`H3` 去掉 double `applyFilter`)。
+- **paimon**:SDK `CachingCatalog`(默认开)已提供 in-memory tableCache/partitionCache/manifestCache;连接器又已恢复 3 个 legacy 语义缓存。加连接器级 table 缓存会与 SDK 缓存重复。只有 `PA-1` 一个 P2 一致性清理值得做。
+
+**(2) 强推所有连接器上同一种缓存形状 = 与 per-connector 语义作对。** 被缓存 value 类型、key、失效、授权语义天生不同(一个 iceberg `Table` 不是 `HmsTableInfo` 不是 paimon `Table` 不是 ODPS `Table`)。Trino 明确拒绝"一个缓存统治所有"(INPUT B §3),Doris 若比 Trino 抽象得更狠会打同一场必输的仗。**正确姿势:统一 toolkit,按连接器各建缓存。**
+
+**(3) hive 已 framework-aligned,勿冗余套用。** hive 已有 `CachingHmsClient`(4 缓存)+ `HiveFileListingCache` + `ConnectorPartitionViewCache` + sibling memo + 写事务快照;`HMS-H4`(ACID `getAcidState` 未缓存)是 snapshot/write-id 依赖、legacy-parity 正确,**应保持不缓存**;`HMS-H6` 罕见二次 RPC 的 belt-and-suspenders per-statement memo **不划算**(HMS `getTable` 远比 iceberg `loadTable` 便宜)。hive 的实际工作只有 doc 修。
+
+**(4) 一致性回退风险(低但需记):** §5.2 的 hive 写后读 TTL 窗口;若强行给 hudi/es 的 freshness-敏感数据加 cross-query 缓存会引入陈旧读——故 hudi 的 metaClient/分区缓存要 TTL+REFRESH 有界,es 的 shard routing 保持 per-statement。
+
+---
+
+## 8. 给用户的开放问题 / 需拍板的决策
+
+1. **范围(scope):** 本轮只打 hudi(唯一 P1 真缺口),还是把 3 个 P1 常数倍收尾(maxcompute `MC-1`、es `ES-F1/F2`)一并纳入?我的建议是 **hudi 单独立项 + maxcompute/es 各一个小 PR**,jdbc/paimon/trino P2 进 backlog。请拍板是否同意这个切分。
+
+2. **排序(sequencing):先建共享底座还是先按连接器修?** 由于泛型 table/handle 缓存目前只有 hudi 一个新消费者,我倾向 **per-connector-first**:hudi 直接复用**已存在**的 `CachingHmsClient` + `ConnectorPartitionViewCache` + 一个 hudi 模块内的 metaClient 缓存(建在 `MetaCacheEntry`),等 iceberg 的 3 个格式中立缓存出现第 2 个消费者再上收为 `ConnectorXViewCache`。是否接受"先修后抽象、不投机泛化"?
+
+3. **`check-authz-cache-sharding.sh` 是否现在就通用化?** 现硬编码只盯 iceberg。选项:(a) 现在改成"扫描任意声明 `SUPPORTS_USER_SESSION` 的连接器"(前瞻,防止未来第 2 个 session=user 连接器 unify 出泄漏);(b) 保持 iceberg-only,待真出现第 2 个再改。今天所有推荐的新缓存都 authz-安全,故 (b) 无当下风险,但 (a) 更稳。请选。
+
+4. **是否投入共享的"经 statement scope 解析表"helper?** 它是 INPUT B 眼中"统一连接器缓存框架"的真正含义,但复核显示只有 iceberg(已有)+ hudi 刚需。选项:(a) 提炼成 fe-core 共享接缝供两者用;(b) 让 hudi 在自己模块内做 memo,不提炼。建议 (b)(Rule 2),除非预期近期有第 3 个消费者。
+
+---
+
+## 9. 建议的后续任务空间 (workspaces & sequencing)
+
+建议按 `plan-doc/perf-hotpath-iceberg/` 的布局镜像出 per-connector workspace(每个含:热路径审计、problem-class 归类、修复设计、mutation/验证门):
+
+- **`plan-doc/perf-hotpath-hudi/`(P1,旗舰,先做)** —— 覆盖 `HD-P01/02/03/04/05`。核心交付:(1) per-statement resolved-metaClient+schema memo,由 `HudiConnectorMetadata` 与 `HudiScanPlanProvider` 共享 scope(杀 `HD-P01/02`);(2) 包 `CachingHmsClient`(一行 parity,修 `HD-P04`);(3) cross-query metaClient/table 缓存 + `(table,instant)`-keyed 分区缓存(用 `ConnectorPartitionViewCache`,修 `HD-P03` 的 MTMV/SHOW PARTITIONS 重枚举);(4) per-scan hoist(`HD-P05`)。无 authz、无写事务工作。
+- **`plan-doc/perf-hotpath-maxcompute/`(P1,小)** —— 单点:在每语句一个的 `MaxComputeConnectorMetadata` 内加 `Map<(db,table),Handle>` 并去掉已解析读路径的冗余 `exists()` 探测(修 `MC-1`,顺带 `MC-2`)。
+- **`plan-doc/perf-hotpath-es/`(P1,小)** —— per-scan hoist `EsMetadataState`(修 `ES-F1`)+ 决定 `ES-F2` 的 mapping/field-context 承载方式(enrich `ConnectorTableSchema` 或新增 mapping 缓存——注意这不是"零新增缓存"改动)+ 在 metadata 上 memo schema(`ES-F3`)。**约束:shard routing 保持 per-statement。**
+- **`plan-doc/cleanup-stale-connector-docs/`(doc-only,可随手做)** —— 修 `ConnectorPartitionViewCache` "no consumers yet";修 hive(`CachingHmsClient:85-88`、`HiveFileListingCache:72-74`、`HiveConnector.wrapWithCache:667-668` 及 `:118,126-127` sibling 字段注释)、hudi(`HudiConnectorMetadata.java:391`、`HiveConnectorMetadata.java:410,1802,1942,1972,2007`)、maxcompute(`beginTransaction:332`、`MaxComputeWritePlanProvider:74`)的"dormant / 未在 SPI_READY_TYPES / never called"陈旧注释。
+- **`plan-doc/perf-hotpath-backlog-p2/`(P2,可延后)** —— paimon `PA-1`;jdbc `HP-1/HP-2`;trino `H1/H2/H3`(仅 CPU 清理,不加 L1 缓存);hive 写后读一致性(给 `CachingHmsClient` 加写路径失效或 bypass 读)。
+
+**排序建议:** 先 hudi(唯一 P1 真缺口、收益最大)→ 并行 maxcompute + es 两个小修 → doc-only 清理随时 → P2 backlog 按热点 profile 触发。共享底座泛化(iceberg 3 缓存上收 + `check-authz-cache-sharding.sh` 通用化)留待 §8 决策拍板后,且仅在出现第 2 个消费者时启动,避免投机抽象。
\ No newline at end of file
diff --git a/plan-doc/connector-cache-unification/HANDOFF.md b/plan-doc/connector-cache-unification/HANDOFF.md
new file mode 100644
index 00000000000000..5029fdc58d8cb0
--- /dev/null
+++ b/plan-doc/connector-cache-unification/HANDOFF.md
@@ -0,0 +1,129 @@
+# 🤝 Session Handoff — 连接器缓存框架统一 (connector cache unification)
+
+> **滚动文档**:顶部「下一步」区每 session 结束**覆盖式更新**,只保留下一个 session 必须的上下文;
+> 底部「稳定区」(推进流程 / 铁律 / build 坑 / 并发探测)**勿随意覆盖**,是跨 session 常驻规则。
+> 完成明细进 `git log` + 各兄弟空间的 `designs/` + 本空间 [`progress.md`](./progress.md)。
+> **本空间是"伞形协调空间"**:调研结论在 [`00-research-report.md`](./00-research-report.md) + [`connectors/*.md`](./connectors/) + [`data/connector-audits.json`](./data/connector-audits.json);
+> 真正的逐连接器执行**各自另开兄弟空间**(`plan-doc/perf-hotpath-/`,镜像 `plan-doc/perf-hotpath-iceberg/` 布局),不并进本空间(报告 §9)。
+
+---
+
+## 📖 新 session 开场读序
+
+```
+1. Read 本 HANDOFF.md ← 你在这(下一步 + 稳定区规则)
+2. Read tasklist.md ← workstream 追踪到哪了、决策签了没
+3. 需要某条发现细节时才 Read 00-research-report.md 对应节 / connectors/.md / connector-audits.json
+ —— 别默认全读;报告很长,按需取。
+4. 参考实现模板:plan-doc/perf-hotpath-iceberg/(README=流程 / HANDOFF / tasklist / designs)
+ 问题类:plan-doc/perf-heavy-op-hot-path-problem-class.md
+```
+
+---
+
+# 🆕 下一步(覆盖区)
+
+## 🎯 上一 session 收尾(2026-07-24 (7),HEAD `7df22cd1c71`):owner 点名三项 = **2 做 + 1 证伪**
+
+owner 2026-07-24 点名的三项 P2 已收尾(详见 [`progress.md`](./progress.md) "2026-07-24 (7)" + 兄弟空间 `perf-hotpath-paimon` / `perf-hotpath-jdbc`):
+
+1. **paimon 分区列举去重** ✅ commit `59b65912104`:抽 `cachedPartitions` 让 `listPartitionNames/Values` 与 `listPartitions` 共享 `partitionViewCache`。**owner 拍板"走缓存"**——SHOW PARTITIONS/`partition_values()` 新鲜度从"每次重算"→24h TTL+REFRESH(与裁剪路径/Trino 自洽;与 hive 故意实时不一致但 paimon 只读无写后读问题)。26 测试 + 3-lens 净室。
+2. **jdbc 两处取列** ✅ commit `7df22cd1c71`:读侧(`getColumnHandles`×2 + `getTableSchema`)+ 写侧(`buildInsertSql`)经新 `ConnectorStatementScopes.JDBC_COLUMNS` 每语句作用域记忆化原始列,**读写自动共享(一个改动点修两处)**。**owner 拍板"语句作用域统一去重"**。199 测试 + 3-lens 净室(修 `jdbcTypeToConnectorType` 原地改 allowNull 的共享-mutable 不变量)。
+3. **hive 写后读一致性** 🚫 **非 bug(侦察证伪,owner 关闭)**:前提"写不失效"错——每次 `hms` INSERT 提交后 `PluginDrivenInsertExecutor.doAfterCommit → RefreshManager.handleRefreshTable → connector.invalidateTable` 已**同步全表失效**(协调 FE 同 session 写后读到新数据)。残留仅跨 FE 编辑日志回放毫秒窗口(通用 Doris 非 hive 专有)+ 过度失效(fe-core 侧 perf 非正确性,碰铁律 A)。印证 `execution-blueprint-overestimates-recon-first`。
+
+**下一 session 议程(无 owner 新指令时)**:本条线主体已收官。剩余候选(均可延后,等 owner 点名或热点触发):① **各连接器 e2e 统一补**(需集群,paimon/mc/es/hudi/jdbc 均标"待集群");② **trino P2 纯 CPU 清理**(TRINO-H1/2/3,未排期——owner 未点名 + 受"禁加 L1 缓存"硬约束);③ **iceberg 5 缓存全量收敛**(远期,PR-3 已只做安全 ttl 去重)。动任何一项前仍守:按 HEAD 重侦察、铁律 A(fe-core 0 行)、净室对抗复审。
+
+## 当前状态(历史,2026-07-23 快照 — 以上「议程」为准;round-1 + 门禁移除 + 注释清理均已完成)
+
+- **调研 + 设计均完成;owner 4 决策已签字 + 设计后二次确认**([`tasklist.md`](./tasklist.md) §0)。
+- **设计定稿** → [`designs/foundation-design-FINAL.md`](./designs/foundation-design-FINAL.md)(配 `foundation-design-draft.md` + `review-1..3.md`)。11-agent 只读设计调研 + 3 路对抗评审,全程逐符号核对 HEAD。
+- **底座 PR-0/1/2 已落地(下方「进行中」为准);连接器消费 PR-3~7 + e2e 未跑。** 设计里 `file:line` 是 2026-07-23 HEAD 侦察快照,动码前须按 HEAD 重侦察。
+- **核心结论**:走"先建底座",但**底座几乎现成** → 整套 A/B/C + hudi/mc/es 消费 **全程 0 行 fe-core 改动**(D4 原"提炼进 fe-core"被侦察推翻:per-statement memo 底座已在 `fe-connector-api`,owner 二次确认不动 fe-core;iceberg 本轮就改挂)。
+
+## 历史:底座 PR-0/1/2 + ttl 去重 + hudi/mc/es round-1 + **owner 点名 P2 三项(paimon/jdbc 做、hive 证伪)全部收尾**(2026-07-23/24);门禁改**删门禁+ATTN**、陈旧注释清理已完成;各连接器 e2e 待集群;动每个文件前先按 HEAD 重侦察
+
+**maxcompute(WS-MC)round-1 完成**(commit `58daadd10e0`,见 `plan-doc/perf-hotpath-maxcompute/`):per-statement `Map<(db,table),handle>`(CHM)present-only memo,收冗余 ODPS `exists()` k×→1× + Table reload 每表 1×;0 fe-core;120 测试绿 + 两处变异 + 净室复审(concurrency 经 verify REFUTED)。
+
+**es(WS-ES)round-1 完成(owner 拍板"三件全做")**(commits `7d74ba1161b` + `7466b354901`,见 `plan-doc/perf-hotpath-es/`):①per-scan hoist(`EsScanPlanProvider` memo `EsMetadataState`,plain 字段——ES 非 batch)②per-statement schema memo(`EsConnectorMetadata` CHM,镜像 mc)③cross-path raw-mapping 经每语句 `ConnectorStatementScope`(新命名空间 `ES_INDEX_MAPPING`,schema/scan 两路共享,**分片/节点绝不入作用域**)。每语句 getMapping/shard/node 各→1;**0 fe-core**;94 测试绿 + 三处变异 + 4-lens 净室(parity/concurrency/freshness HOLD,补 `ShardRoutingNeverSharedViaScope` 钉硬约束)。
+
+**至此三个 P1 连接器(hudi 旗舰 / mc / es)round-1 全部收尾。** 剩余:P2 backlog(paimon PA-1 / jdbc HP-1·2 / trino CPU / hive 写后读,热点触发)、各连接器 e2e 统一补(需集群)。iceberg 5 缓存全量收敛仍延后(PR-3 已只做安全 ttl 去重)。**WS-DOC 陈旧注释清理已完成**(2026-07-24 (6):原点名的 HMS-flip 注释早已更新;另清 iceberg+maxcompute 写/过程"翻闸前休眠"陈旧注释 9 文件纯注释;hudi 增量真休眠留存)。
+
+**⚠ 授权缓存门禁项(原 PR-7 门禁通用化)已关闭——不是通用化而是删除(owner 2026-07-24 拍板)。** 尝试把 `check-authz-cache-sharding.sh` 改成"模块内构造点扫描 + 结构化验证置空",经**两轮对抗净室复审**证伪:shell 无法可靠判断"缓存在每用户模式下是否真置空"(须理解任意 Java 布尔/多行语法——复合 `&&`/`||`、多行 lambda loader、字符串字面量、嵌套三元式;两轮共暴露 10+11 个误报/漏报,误报会挡合法构建、漏报会放走泄漏,每修冒新边界)。而置空正确性**已由运行时 `IcebergConnectorCacheTest` 证明**(比静态检查强),门禁的机器验证是越界。owner 拍板**删掉整个门禁**(`tools/check-authz-cache-sharding.sh` + 自测 + `fe-connector/pom.xml` 执行块),改在 `IcebergConnector.java` 加显式 `ATTN` 注释(讲清 list!=load 风险、每缓存已构造函数置空、新增缓存必须置空且被单测覆盖、无构建门禁靠评审+单测)。落地严格 4 文件、`mvn validate` 过。见 [`progress.md`](./progress.md) "2026-07-24 (5)"。**通用教训**:shell 门禁只配"存在性/前缀"类不变量;要理解语言语义就不是它的活。
+
+设计已 owner 确认,已开工。**PR-0 完成**(预编译执行连接器作用域 reset 回归守门 + 外表可达性查实 + FINAL 设计机制描述更正;见 [`progress.md`](./progress.md) "2026-07-23 (3)")。**PR-1 完成**(通用缓存封装升格为 `ConnectorMetadataCache`,纯重命名 + 构造器加 `entryName`,66 分区视图缓存测试证零变化;commit `a804145faaa`,见 "2026-07-23 (4)")。**PR-2 完成**(语句作用域通用 helper `ConnectorStatementScopes.resolveInStatement` 放 `fe-connector-api`=**0 行 fe-core**,iceberg `sharedTable` 改委派 byte-identical;8+7 单测 + iceberg 全模块 1133 测试 0 失败 + 4 路对抗净室复审 PARITY_HOLDS + Rule-9 变异验证;commit `ae8c925074d`,见 "2026-07-24")。
+
+**PR-3 重定范围(owner 2026-07-24 拍板)**:原计划"iceberg 5 手写缓存全量收敛到 `ConnectorMetadataCache`"经动码前重侦察 + 向 owner 讲清成本后**改为只做安全 DRY、全量收敛延后**——收敛零功能收益、改动面宽(~19 文件/重写 ~37 测试/6 重载构造函数波及/调用点退化成字符串四元组键),且框架已被 3 连接器分区视图缓存证明可用、hudi/mc/es 不依赖它,Trino 亦是"共享原语+各连接器自持缓存"。**已落地的安全那一块**:新增 `CacheSpec.ofConnectorTtl` 折叠"`ttl≤0` 禁用"契约,**6 处**复制三元式(`IcebergComment/Format/LatestSnapshot/Partition/Table` + `PaimonLatestSnapshotCache`)改调它,逐字节等价、既有测试全绿(见 "2026-07-24 (2)",commit `e1adddf22e8`)。**旗舰 hudi Round 1 已完成**(`plan-doc/perf-hotpath-hudi/`:文档 + HMS 缓存 + 每语句 memo 三块落地,commit `26690775c81` 为旗舰 memo——采"两块独立 per-statement memo",动码前重侦察推翻蓝图多处、合并版经净室复审后 owner 拍板退回零耦合方案)。**下一步 = mc / es 两个小 PR**(`WS-MC` / `WS-ES`),或 hudi Round 2 延后项(跨查询缓存 `PERF-H04` 等)。
+
+**设计里行号/乘数是 2026-07-23 快照,动每个文件前按符号重 grep 确认**(memory `execution-blueprint-overestimates-recon-first`;已累计侦察更正——PR-0 机制"新 context"实为"复用+显式 reset"、PR-1"删兼容子类"实为无子类可删、PR-2 `rewritableDeleteSupply` 须留 iceberg 私有、PR-3 全量收敛经侦察判为高成本零收益已延后)。**若日后重启 iceberg 收敛**,侦察须重确认:5 缓存全独立 `final class` 建于 `MetaCacheEntry`、entry 名 hyphen(`iceberg-table` 等非 `iceberg.table`)、`formatCache` 挂 `IcebergScanPlanProvider`/`IcebergConnectorMetadata` 双持、`invalidateDb` 现走 `Namespace.of(db)+id.namespace().equals`(收敛须证单层 namespace 等价 String-equals)。
+
+## 实施路线(8 个独立 PR,foundation-first;详见 FINAL 设计 §Sequencing / §Risk register / §Verification plan)
+
+| PR | 主题 | 关键约束 / 守门 |
+|---|---|---|
+| **PR-0** ✅(2026-07-23 完成) | 验预编译 `EXECUTE` 重执行时 statement scope 是否每次刷新 | 已加 `ConnectorStatementScopeTest.executeCommandResetsConnectorScopePerExecution`(驱动 `ExecuteCommand.run()`、变异验证仅该测试变红);**机制更正**:不是"新 context"而是复用 context + `ExecuteCommand.java:95` 显式 reset;**外表可达性已查实**(走普通 `executor.execute()` 路,非 OLAP 短路) |
+| **PR-1** ✅(2026-07-23 完成,commit `a804145faaa`) | 升格 `ConnectorPartitionViewCache[V]`→`ConnectorMetadataCache[V]`;`PartitionViewCacheKey`→`ConnectorTableKey`;构造器加 `entryName` 参数;iceberg/hive/paimon 改挂;修陈旧 javadoc。**更正**:无"旧类可删"(是重命名非删除);**收窄**:ttl 去重 + 预解析 CacheSpec 构造器推迟到 PR-3(避免二次翻动 iceberg 手写缓存) | ✅ `install -pl cache,hive,iceberg,paimon -am` BUILD SUCCESS;66 分区视图缓存测试 0 失败,条目名/配置项/键逐字节不变 |
+| **PR-2** ✅(2026-07-24 完成,commit `ae8c925074d`) 语句作用域 helper(`fe-connector-api`)+ iceberg 改挂 | 加 `ConnectorStatementScopes.resolveInStatement` + namespace 注册表(`ICEBERG_TABLE`);iceberg 私有包装改委派(key 逐字节不变,`rewritableDeleteSupply` 留私有) | ✅ **fe-core 0 行**;8+7 单测 + iceberg 全模块 1133 测试 0 失败 + 4 路对抗净室 PARITY_HOLDS + Rule-9 变异(queryId→sessionId 恰红 2 byte-key 测试) |
+| **PR-3** ✅(2026-07-24 **重定范围**:只做安全 ttl 去重,全量收敛延后) | 新增 `CacheSpec.ofConnectorTtl` 折叠"`ttl≤0` 禁用"契约,6 处三元式改调它(iceberg 5 + paimon 1),逐字节等价 | ✅ BUILD SUCCESS;`CacheSpecTest`+1、6 缓存既有测试原样全绿、iceberg 1134/paimon 379 0 失败。**收敛部分延后**:5 缓存类/键/测试原样保留,`invalidateDb` Namespace→String parity、pre-resolved 名/loadCount 访问器等出现真实需要再上收 |
+| **PR-4** 旗舰 hudi | metaClient+schema 每语句 1 次;HMS 走 `CachingHmsClient`;`(表,已完成instant)` 跨查询分区缓存;per-scan hoist | **记忆"不可关闭投影"非原始 metaClient**(scope 末关所有 AutoCloseable);instant **每语句重取最新已完成**、只缓存分区列表;pom 加 `fe-connector-cache` + **验 Caffeine≥2.9.3 自带**;**e2e 必需**(异构+独立 hudi-on-HMS + 并发提交分区列举) |
+| **PR-5** maxcompute(小) | `getTableHandle` 每语句 memo,消冗余远程 `exists()`(14–17→1) | 连接器侧、fe-core 0 行;仅 in-statement `buildConnectorSession` 路径 |
+| **PR-6** es(小) | `EsMetadataState` per-scan hoist(2→1);raw mapping 挂语句作用域态 | **分片路由必须每语句、拆 `fetch()`**、绝不跨查询缓存 |
+| **PR-7** ~~门禁通用化~~ 🚫 **撤销→删门禁**(owner 2026-07-24) | 结构化验证置空正确性 shell 做不稳(两轮对抗复审 10+11 误报/漏报);运行时 `IcebergConnectorCacheTest` 已证明置空 → **删整个门禁 + 加 `ATTN` 注释** | 已落地;见 progress.md "2026-07-24 (5)" |
+
+- **PR-1 + PR-2 是底座**,解锁 PR-3/4/5/6;**PR-7 独立**;**PR-0 阻塞 PR-2**。
+- **连接器 PR(4/5/6)用兄弟空间**(`plan-doc/perf-hotpath-/`,镜像 iceberg 布局)逐项立项;**底座 PR(1/2/3/7)跨连接器**,进本伞形空间 `designs/`。
+- **本轮全程守铁律 A(fe-core 0 行)**——实施中若发现某步"不得不碰 fe-core",**停手交 review**(大概率又是侦察能推翻的伪需求)。
+
+---
+
+# 🧱 稳定区(勿覆盖)
+
+## 从调研到执行的推进流程(每个 workstream)
+
+> 一次一个 workstream 串行推进;这些是**性能/结构修复 → 必须行为不变**,复核与"证明只减不改"是重点。对齐 `step-by-step-fix` skill 与参考空间 `perf-hotpath-iceberg/README.md` 的立项流程。
+
+```
+1. 拿 owner 签字(D1 决定本 workstream 是否在本轮范围内)。
+2. 新开兄弟空间 plan-doc/perf-hotpath-/(镜像 perf-hotpath-iceberg 布局),
+ 把该连接器的发现(HD-P0x / MC-x / ES-Fx …)拆成 PERF-NN 逐项。
+3. 【动码前必做】按 HEAD 重侦察:重新 grep 行号、重新确认 ①重操作真实成本 ②乘数 ③是否真无缓存兜住。
+ 调研是估算,复核可能缩小/推翻某条(如 hudi HD-P03 对过滤查询已去重到 ~1 次)。
+4. 写设计文档 + 设计红队(clean-room 对抗,本项目偏好)。
+5. 实现:最小改动、守铁律、连接器侧、匹配风格。
+6. 验证:相关 UT/e2e 全绿(parity 禁回归)+ 补调用计数守门(如 metaClient build 从 N 降到 1)。
+ e2e 需集群,本地不跑的留标注。
+7. 独立 commit:[perf](catalog) fe-connector-: (PERF-NN)。commit log 全英文。
+8. 更新该兄弟空间 tasklist/HANDOFF;回本伞形空间更新 tasklist workstream 状态 + append progress.md。
+```
+
+## 🔒 铁律(违反即返工,动码前记牢)
+
+1. **fe-core 源只出不进 + 禁 scaffolding 搬迁**(memory `fe-core-source-isolation-iron-rules`):新缓存/memo 一律放**连接器侧**(`fe-connector-` / handle / `fe-connector-cache` 工具箱),**不得**为省事把 table 缓存/属性解析/派生 helper 塞进 fe-core。hudi/mc/es 的所有推荐修复都在连接器侧,天然合规;**D4 若选"提炼进 fe-core"= 净增 fe-core SPI,碰铁律 A,须用户显式签字**(报告建议不提炼)。
+2. **fe-core 通用节点保持 connector-agnostic**:不在 `PluginDrivenScanNode` 等通用 SPI 层写 source-specific 分支;通用热路径若要改须证 **byte + cost 对所有连接器双不变**。本轮推荐修复**都不碰** fe-core 通用节点(纯连接器侧),若某项需要碰,停手交 review。
+3. **fe-core 不解析属性**(memory `catalog-spi-no-property-parsing-in-fecore`):属性派生放插件侧组装 → BE thrift → 回传。
+4. **新缓存准入必查 authz**(报告 §6):给任一连接器加"名字为 key 的 cross-query 缓存"前,先确认该连接器**是否 vend per-user 凭证**。今天 7 个非-iceberg 连接器都是**单一 catalog 身份**(hudi/mc/es/jdbc/trino/paimon/hive 逐一核实),故名字为 key 的缓存 authz-安全、**无需 Layer-3 bypass**。**但这是"当前"安全非"结构"安全**:一旦某连接器未来接 per-user 凭证 vending(REST-OIDC 等),名字为 key 的缓存立刻变泄漏面,而 `check-authz-cache-sharding.sh` 现只盯 iceberg(见 D3)。
+5. **freshness-敏感数据的 cross-query 缓存必 TTL + REFRESH 有界**:hudi 的 metaClient/分区缓存要有界;**es 的 shard routing 绝不能 cross-query 缓存**(ES rebalance/refresh 模型),保持 per-statement。
+6. **反指别硬套**(报告 §7):jdbc / trino / paimon / hive **不要**再加 iceberg-式重缓存(廉价透传 / 委托嵌入式引擎缓存 / SDK 已缓存 / 已 framework-aligned);详见 [`tasklist.md`](./tasklist.md)「反指/不立项」。
+
+## 🧰 build / 验证坑(继承自参考空间 + 本程序特有)
+
+1. **maven 用绝对 `-f /fe/pom.xml`**(cwd 跨调用持久,`cd` 破相对路径);`${revision}` 未 flatten → 必 `-am`;测试用 **`install`** 而非 `test`(`-am test` 不产 shade jar)。
+2. **测试必加 `-Dmaven.build.cache.enabled=false`**(否则 surefire 静默跳过);**别用 `mvn -q` 跑测试**(抑制 `BUILD SUCCESS`/`Tests run`);grep 日志 `BUILD SUCCESS` + `Tests run:`。
+3. **别 `nohup … &` 套 `run_in_background`**(maven 变孤儿假 exit 0);后台跑直接 `mvn … >> log 2>&1`,**通知里 exit code 是 echo 的**,读日志 `BUILD SUCCESS` 行。
+4. **动 fe-core 者两段验**(若某项不得已碰 fe-core):连接器模块 `-pl -am` 反应堆不含 fe-core;fe-core 改动须另 `mvn test -pl fe-core -am -Dtest=… -f /fe/pom.xml`。
+5. **⚠ hudi 特有**:`fe-connector-hudi` 目前**零 `fe-connector-cache` 依赖**(pom 没引工具箱,也没包 `CachingHmsClient`)——WS-HUDI 第一步要**改 pom 引入工具箱**,这是 hudi 独有的前置(hive/paimon/mc 已引)。
+6. **checkstyle 主源 ≤120 且扫 test 源**;import 序 `SAME_PACKAGE→THIRD_PARTY→STANDARD_JAVA`,组内字母序组间空行。
+
+## 🔎 并发探测(动码前)
+
+- 本 worktree:`find fe -newermt '-120 sec'`(滤 `/target/`)+ `pgrep -a -f maven`;`/mnt/disk1/yy/git/doris` 是另一 worktree,不冲突(memory `concurrent-sessions-shared-worktree-hazard`)。
+- 本 worktree 的 `.git` 是**文件**不是目录(linked worktree)——rebase 脚本用 `git rev-parse --git-path`(memory `worktree-gitdir-is-a-file`)。
+- 提交只精确 `git add` 本任务文件:工作树有大量无关 untracked(`.claude/`、`docker/`、`regression-conf.groovy` 本就脏),**禁 `git add -A`**。
+
+---
+
+# 🔗 关系
+
+- **参考实现(模板)**:[`plan-doc/perf-hotpath-iceberg/`](../perf-hotpath-iceberg/)(iceberg 那批被删缓存的逐条修复,`PERF-01..11`)+ [`perf-heavy-op-hot-path-problem-class.md`](../perf-heavy-op-hot-path-problem-class.md)(本调研套用的"热路径重操作放大"问题类)。
+- **主线**:`plan-doc/HANDOFF.md`(catalog-spi 主线)——**并行但不混流**。
+- **协作规范 / context 预算 / subagent 纪律**:沿用 [`../AGENT-PLAYBOOK.md`](../AGENT-PLAYBOOK.md)。
+- **交接纪律**(memory `handoff-discipline-per-phase`):每轮完成即更新 HANDOFF/tasklist;每轮起步先读 HANDOFF 再对照 HEAD 真实代码 review(计划/行号可能过时)。context 用量过 30% 就找干净节点覆写 HANDOFF 并通知用户开新 session(memory `session-handoff-at-30pct-context`)。
diff --git a/plan-doc/connector-cache-unification/README.md b/plan-doc/connector-cache-unification/README.md
new file mode 100644
index 00000000000000..12ade93bc8f93e
--- /dev/null
+++ b/plan-doc/connector-cache-unification/README.md
@@ -0,0 +1,51 @@
+# 📦 调研任务空间 — 连接器缓存框架统一 (connector cache unification)
+
+> **独立调研空间**,与 `plan-doc/HANDOFF.md`(catalog-spi 主线)及 `plan-doc/perf-hotpath-iceberg/`(iceberg 热路径修复,本报告的参考实现)**并行但不混流**。
+> **一句话任务**:参考 commit `0b4f72582e7` 为 iceberg 建了一套「统一元数据缓存 + per-statement metadata funnel」框架;本调研判定**这套框架是否/如何推广到其余连接器**,目标 (1) 统一连接器缓存框架、(2) 保证热路径(查询 + 写入)的缓存性能与一致性。
+> **基线**:`branch-catalog-spi` @ HEAD(2026-07-22)。参考 commit `0b4f72582e7`。
+
+---
+
+## 🚩 结论(先看这一句)
+
+**框架三层里,承重的引擎接缝(Layer-2 per-statement `ConnectorMetadata` funnel + 其 build-gate + 共享底座 `fe-connector-cache` + 泛型 `ConnectorPartitionViewCache` + fe-core 通用 `shouldBypass*` 谓词)已经是连接器无关、对全部 7 个 `SPI_READY_TYPES` 生效的通用设施——"统一(goal-1)"这一半其实已经做完。** iceberg 特有的只是"填充物"(6 个连接器侧缓存)。因此推广 = **有选择地填充,不是重新造框架**:唯一 loop-amplified(iceberg-式)的 P1 真缺口是 **hudi**(缓存最薄:零连接器侧缓存、裸 `ThriftHmsClient`、metaClient 每 pass 重建 5–6 次);maxcompute / es 是少量 constant-factor(2–4x)P1 收尾;jdbc / es / trino / paimon 结构上**反指**再加重缓存(廉价透传 / 委托嵌入式引擎 / SDK 已缓存)。
+
+> **Trino-correct 原则**(经 Trino 源码核实,见 appendix B):正确的统一粒度是**工具箱**而非"一个大缓存"——被缓存对象的类型、失效语义、授权语义天生按连接器不同。Doris 已站在这条路径上。
+
+---
+
+## 📄 文件索引
+
+| 文件 | 内容 |
+|---|---|
+| [`00-research-report.md`](./00-research-report.md) | **主报告(交付物)** — 9 节:结论先行 / 框架解剖 / 横向矩阵 / goal-1 统一建议 / goal-2 热路径+一致性路线图 / session=user 授权跨连接器影响 / 风险与反对意见 / 需拍板决策 / 建议后续任务空间 |
+| [`appendix-A-framework-taxonomy.md`](./appendix-A-framework-taxonomy.md) | 框架三层 + 共享底座的精确解剖(哪些已通用 / 哪些是 iceberg-only 模式),全部挂 HEAD `file:line` |
+| [`appendix-B-trino-reference.md`](./appendix-B-trino-reference.md) | Trino 的 per-transaction metadata + 连接器缓存模型,逐条映射到 Doris 现状并指出 gap(遵循"连接器 SPI 决策前先读 Trino") |
+| [`appendix-C-funnel-universality.md`](./appendix-C-funnel-universality.md) | 结构性论断核验:funnel 是否已覆盖每个连接器 + 每个连接器的 ConnectorMetadata 是否 memoize 已加载表(per-connector 表) |
+| [`appendix-D-critic-punchlist.md`](./appendix-D-critic-punchlist.md) | 对主报告的矛盾/完整性对抗复核清单(5 条 minor 已在主报告修正;附通过项) |
+| [`connectors/*.md`](./connectors/) | **7 份 per-connector 审计附录**(hive / hudi / paimon / maxcompute / jdbc / es / trino),各含:迁移状态、现有缓存表、funnel 参与、热路径重复加载审计表、授权/一致性、所需框架件、优先级,**末尾附对抗式复核结论(verdict + 更正表)** |
+| [`data/connector-audits.json`](./data/connector-audits.json) | 7 连接器结构化审计数据(缓存清单 / hotPathFindings / 复核 verdict),矩阵与路线图的原始来源 |
+
+---
+
+## 🔬 本调研如何产出(方法与可信度)
+
+- **19-agent 编排研究**(clean-room 对抗式):3 个框架 agent(taxonomy + Trino 参考 + funnel 通用性核验)‖ 7 个连接器 × (审计 → **独立对抗验证**)‖ 综合 → 矛盾/完整性 critic。共 ~2.4M token,0 error。
+- **全部论断挂 HEAD `file:line`**,agent 被要求"行号信 HEAD 不信文档/commit message";7 连接器结论各经一个**默认怀疑**的验证 agent 复核:**5 CONFIRMED / 2 ADJUSTED**(hudi、es;更正已折进主报告与对应附录)。
+- **⚠ 这是纯调研,未改任何产品代码,未跑 e2e。** `file:line` 为调研时(2026-07-22 HEAD)快照,落地实现时以 `grep` 为准。所有"倍数/成本"是审计估算,建议在真正立项时用调用计数守门(对齐 `perf-hotpath-iceberg` 的度量门做法)复测。
+
+---
+
+## 🧭 需 owner 拍板的 4 个决策(详见主报告 §8)
+
+1. **范围**:本轮只打 hudi(唯一 P1 真缺口),还是把 maxcompute `MC-1`、es `ES-F1/F2` 两个小修一并纳入?(建议:hudi 单独立项 + mc/es 各一小 PR,余进 backlog)
+2. **排序**:先建共享底座泛化,还是 per-connector-first?(建议 per-connector-first——泛型 table/handle 缓存目前只有 hudi 一个新消费者,出现第 2 个再上收,避免投机抽象 / Rule 2)
+3. **`check-authz-cache-sharding.sh` 是否现在就通用化?**(现硬编码只盯 `IcebergConnector.java`;今天所有推荐新缓存都 authz-安全,故可延后,但前瞻通用化更稳)
+4. **是否投入共享的"经 statement scope 解析表"helper?**(Trino 眼中"统一"的真正含义,但复核显示只有 iceberg+hudi 刚需;建议让 hudi 在自己模块内做,不提前提炼)
+
+---
+
+## 🔗 关系
+
+- **参考实现**:`plan-doc/perf-hotpath-iceberg/`(iceberg 那批被删缓存的逐条修复)+ `plan-doc/perf-heavy-op-hot-path-problem-class.md`(本调研套用的"热路径重操作放大"问题类)。
+- **建议后续任务空间**(见主报告 §9):`plan-doc/perf-hotpath-hudi/`(P1 旗舰)、`perf-hotpath-maxcompute/`、`perf-hotpath-es/`(均小)、`cleanup-stale-connector-docs/`(doc-only)、`perf-hotpath-backlog-p2/`。各连接器建**各自兄弟空间**,不并进本空间。
diff --git a/plan-doc/connector-cache-unification/appendix-A-framework-taxonomy.md b/plan-doc/connector-cache-unification/appendix-A-framework-taxonomy.md
new file mode 100644
index 00000000000000..341b7950d2e385
--- /dev/null
+++ b/plan-doc/connector-cache-unification/appendix-A-framework-taxonomy.md
@@ -0,0 +1,128 @@
+I have all the grounding I need. Here is the taxonomy.
+
+---
+
+# Taxonomy of the "framework" introduced by `0b4f72582e7`, at HEAD (`branch-catalog-spi`)
+
+Every file:line below is verified against HEAD, not against the commit or plan-docs.
+
+Scope note: the reference commit has three layers. Grounding at HEAD confirms the commit's own framing but sharpens one point — **Layer 2 (the funnel) is 100% connector-agnostic and already live for all 7 `SPI_READY_TYPES`; Layer 1 (the 6 caches) and one of the two gates are iceberg-only *implementations of a replicable pattern*, and the substrate to generalize them (`fe-connector-cache`) already exists and is already partly adopted.**
+
+---
+
+## 1. Layer 2 — the connector-agnostic per-statement `ConnectorMetadata` funnel
+
+**What it is.** A per-statement memoization arena (the Doris analog of Trino's per-transaction `CatalogMetadata`) that guarantees *one `ConnectorMetadata` instance per (statement, catalog)*, so every read / scan / DDL / MVCC / write resolver in a statement shares one metadata — and, for a connector that memoizes its loaded table on that metadata, one loaded remote table.
+
+Pieces, at HEAD:
+
+- **SPI seam (neutral):** `fe-connector-api/.../connector/api/ConnectorStatementScope.java`. Interface with `computeIfAbsent` (line 45), typed `getOrCreateMetadata` default (lines 55-57), best-effort idempotent `closeAll` default no-op (lines 66-67), and the `NONE` no-op scope that memoizes nothing (lines 70-75). Reached from a connector purely via `ConnectorSession.getStatementScope()`, whose SPI default is `NONE` (`ConnectorSession.java:142-144`) — so any connector that does nothing still compiles and behaves byte-identically to "load every time."
+- **The funnel itself:** `fe-core/.../datasource/plugin/PluginDrivenMetadata.get(session, connector)` (lines 53-71). Keyed `"metadata:" + catalogId` (line 70); builds via `connector.getMetadata(session)` at most once per statement. It also pins the *building identity* under `"metadata-identity:" + catalogId` and throws `IllegalStateException` if a second Doris principal reuses the instance (lines 63-69) — turning a would-be cross-user credential reuse into a hard error. This is the **only** file in fe-core allowed to call `Connector#getMetadata` (enforced by gate §5).
+- **The arena impl:** `fe-core/.../connector/ConnectorStatementScopeImpl.java`. A `ConcurrentHashMap` (line 44) so off-thread scan pumps that reuse the one session reach the same scope; `computeIfAbsent` (lines 49-51); **two-tier `closeAll`** (lines 64-98, guarded by `closed` for idempotency): pass 1 finalizes any `CatalogStatementTransaction` (rolls back an uncommitted txn), pass 2 closes every remaining `AutoCloseable` value (the memoized metadata) — txn finalized *before* the metadata it was minted from is closed.
+- **The physical home:** `fe-core/.../nereids/StatementContext.java`. Field `connectorStatementScope` (line 209); lazily built by `getOrCreateConnectorStatementScope()` (lines 656-661); dropped/closed per prepared-statement execution by `resetConnectorStatementScope()` (lines 672-677); **fallback close** in `close()` for statements that never reach a coordinator (external DDL/SHOW/DESCRIBE/EXPLAIN/foreground ANALYZE), guarded by `isReturnResultFromLocal` (lines 995-997).
+- **Write-transaction co-holder:** `fe-core/.../datasource/plugin/CatalogStatementTransaction.java`. Minted from the *shared* metadata's write facet via `begin()` (lines 80-84); `finalizeAtStatementEnd()` is the deterministic backstop (lines 100-107). Connector-agnostic — traffics only in neutral `ConnectorWriteOps`/`ConnectorSession`/`ConnectorTransaction` SPI types.
+
+**Lifecycle** (open → memoize → two-tier close):
+1. **Open:** `ConnectorSessionBuilder.captureStatementScope()` (`ConnectorSessionBuilder.java:190-203`) reads the scope off the live `ConnectContext`'s `StatementContext` at session-build time (request thread). **This is *not* gated by connector type** — there is no `iceberg`/type branch; any session built from a live statement gets the real scope, else `NONE`.
+2. **Memoize:** every resolver calls `PluginDrivenMetadata.get(...)`. Verified breadth at HEAD: **58 call sites across 15 fe-core files** (`PluginDrivenExternalCatalog/Table/Database`, `PluginDrivenMvccExternalTable`, `PluginDrivenScanNode`, `PluginDrivenInsertExecutor`, `BindSink`, `PhysicalPlanTranslator`, `PhysicalExternalRowLevelMergeSink`, `ShowPartitionsCommand`, `IcebergRowLevelDmlTransform`, `ConnectorExecuteAction`, `CallExecuteStmtFunc`, `MetadataGenerator`, `JdbcQueryTableValueFunction`).
+3. **Close (two-tier):** primary is the getSplits query-finish callback — `PluginDrivenScanNode` registers `statementScope::closeAll` via `QeProcessorImpl.registerQueryFinishCallback`, skipping `NONE` (lines 1235-1237); fallback is `StatementContext.close()` (line 995). Both idempotent.
+
+**Cross-statement background loaders** are deliberately forced through a `NONE` scope (a read-through, not a hazard): `PluginDrivenExternalCatalog` builds their sessions `.withStatementScope(ConnectorStatementScope.NONE)` (lines 1161, 1168), so a synchronous background `fetchRowCount` cannot capture the live statement's scope.
+
+**Two connector-agnostic memos ride on top** (both byte-identical to re-resolving):
+- `PluginDrivenScanNode.resolveScanProvider()` memoizes the `(handle, provider)` pair keyed on `currentHandle` *identity* via the immutable `ResolvedScanProvider` holder (lines 245-275), so the per-split `getFileCompressType`/`getDeleteFiles`/`classifyColumn` hot path stops re-allocating + TCCL-swapping a provider per split. This is the "generic-node provider memo (C14)."
+- `PluginDrivenScanNode.metadata()` caches this node's funnel result in a volatile field (lines 189, 203-210) so per-method resolvers don't re-hit the scope map.
+
+**Heterogeneous-gateway extension (still Layer-2 mechanics, lives in the hive connector):** `HiveConnectorMetadata.memoizedSiblingMetadata()` (lines 302-304) memoizes the iceberg/hudi-on-HMS **sibling** metadata on *the same per-statement scope*, keyed `"metadata:" + catalogId + ":" + ownerLabel` (the three gateway connectors share one catalogId, so the owner label keeps them distinct — lines 293-295). It uses only `fe-connector-api` types (no fe-core dep).
+
+**Does Layer 2 already apply to ALL of `SPI_READY_TYPES`?** **Yes, universally, with no iceberg gate.** `SPI_READY_TYPES = {jdbc, es, trino-connector, max_compute, paimon, iceberg, hms}` (`CatalogFactory.java:56-57`); every one is a `PluginDrivenExternalCatalog` whose reads/scans go through `PluginDrivenExternalTable`/`PluginDrivenScanNode`, i.e. through the 58 funnel call sites; and `captureStatementScope()` has no type branch. hudi is not its own type — it is served only as a sibling under the hms gateway (`CatalogFactory.java:51-55`), and *that* path also rides the funnel via `memoizedSiblingMetadata`.
+
+**Caveat worth stating plainly:** the funnel guarantees *one metadata per statement*, but whether that collapses repeated remote loads is **per-connector** — it only helps if that connector's `ConnectorMetadata` (or its long-lived `Connector`) actually memoizes the loaded table/schema. Layer 2 is the seam; Layer 1 is what fills it for iceberg. jdbc/es/trino have **no** connector-side cross-query cache at all (verified: no `MetaCacheEntry`/`CacheSpec`/Caffeine anywhere in `fe-connector-{jdbc,es,trino-connector}/src/main/java`), so for them the funnel collapses only the *within-statement* repeats, not cross-query.
+
+---
+
+## 2. Layer 1 — the six iceberg connector-side caches (a replicable *pattern*, catalogued)
+
+All six live in `fe-connector-iceberg/.../connector/iceberg/`, are package-private `final`, and are **all built on the shared `MetaCacheEntry` substrate** (§3) with the identical construction shape `new MetaCacheEntry<>(name, null, spec, ForkJoinPool.commonPool(), false, true, 0L, true)` — contextual-only (caller supplies the loader per call), manual-miss-load (loader runs outside Caffeine's compute lock, single-flight per key, exception propagates unwrapped and un-cached). All are per-catalog, cross-query, and live on the long-lived `IcebergConnector` (a REFRESH/ADD/MODIFY CATALOG rebuilds the connector → drops the caches).
+
+| # | Class (file) | Heavy op memoized | Key | Value | TTL | Invalidation | Scope | Authz gate (see §4) | Format-specific? |
+|---|---|---|---|---|---|---|---|---|---|
+| PERF-01 | `IcebergTableCache.java:59-117` | remote `loadTable` (metastore RPC + `metadata.json` read) | `TableIdentifier` (db.table) | **raw iceberg `Table`** | `meta.cache.iceberg.table.ttl-second`, default 24h; `≤0` disables | table / db(namespace) / all (lines 91-109) | cross-query **+** a per-statement "fat handle" (transient `resolvedTable`) | **null** under `session=user` **or** REST vended-creds (`IcebergConnector.java:243-247`) — value carries FileIO credentials | **Format-neutral in spirit** (every connector loads a table object); value type is iceberg-specific |
+| PERF-02 | `IcebergPartitionCache.java:58-142` | the `PARTITIONS` metadata-table scan (SDK reads every data+delete manifest of the snapshot) | `(TableIdentifier, snapshotId)` | `List` | same 24h knob; `≤0` disables | table / db / all (lines 116-129) | cross-query; shared by MVCC view, `listPartitions`, `SHOW PARTITIONS`, and the 4–6 re-enumerations of one MTMV refresh | **null** under `session=user` (`:254`) — pure metadata but authz-sensitive | **Iceberg-format-specific** (PARTITIONS metadata table, transform partitions) |
+| PERF-03 | `IcebergFormatCache.java:61-145` | #64134's unfiltered whole-table `table.newScan().planFiles()` format fallback | `(TableIdentifier, snapshotId)` | format-name `String` | same 24h knob; `≤0` disables | table / db / all (lines 118-131) | cross-query | **null** under `session=user` (`:260`) | **Format-neutral in spirit** (file-format inference); bare `String` value |
+| PERF-04 | `IcebergManifestCache.java:62-235` | parsing a manifest's files (remote FileIO read) | `IcebergManifestEntryKey` (manifest path + content) | `ManifestCacheValue` (copied `DataFile`/`DeleteFile` list) | **no TTL**, capacity-bounded (`100_000`), **REFRESH CATALOG only** (lines 224-227) | `invalidateAll` only; REFRESH TABLE deliberately keeps it (immutable manifests) | cross-query + cross-table (any table referencing the manifest) | **exempt** — default-off (`meta.cache.iceberg.manifest.enable`) + read after a per-user load (`IcebergConnector.java` marker `authz-cache-exempt`) | **Genuinely iceberg-format-specific** (manifest files) |
+| PERF-05 | `IcebergCommentCache.java:53-111` | per-table `loadTable` that `information_schema.tables` / `SHOW TABLE STATUS` pays N-per-query for the comment | `TableIdentifier` | comment `String` | same 24h knob; `≤0` disables | table / db / all (lines 85-98) | cross-query | **built ONLY** for REST vended-creds **and NOT** `session=user` (`IcebergConnector.java:269-273`); plain catalogs reuse PERF-01 instead | **Format-neutral in spirit** (table comment is universal metadata); bare `String` |
+| (survivor) | `IcebergLatestSnapshotCache.java:54-124` | `currentSnapshot()` + latest-schema read | `TableIdentifier` | `CachedSnapshot(snapshotId, schemaId)` | same 24h knob; `≤0` disables | table / db / all (lines 98-115) | cross-query; read by `beginQuerySnapshot` | **null** under `session=user` (`IcebergConnector.java:231-234`) | **Iceberg-specific** (snapshot id **+** schema id atomic pin — the one deviation from paimon's `long`-only mirror) |
+
+Notes grounded at HEAD:
+- The commit message says the P6 cutover "kept only the latest-snapshot pin" — so `IcebergLatestSnapshotCache` is the pre-existing survivor; PERF-01/02/03/05 restore the dropped halves and PERF-04 re-wires the opt-in manifest cache. At HEAD all six are the current family.
+- Snapshot-keyed caches (PERF-02/03, latest-snapshot) are "always correct" because a snapshot is immutable and a new commit yields a new key; stability across queries comes from the latest-snapshot cache holding the snapshot id stable within the TTL.
+- The **per-scan/per-file hoists** (PERF-06 storage-uri normalizer once per scan via `ConnectorContext.newStorageUriNormalizer`; PERF-11 per-file partition-JSON/values/delete-carrier memo) are part of the same *pattern* but are per-statement/per-scan hoists inside `IcebergScanPlanProvider`, not cross-query cache classes; the one fe-core piece of PERF-11 (the provider memo) is Layer-2 and already connector-agnostic (§1).
+
+**Verdict on Layer 1:** it is a *pattern* (contextual `MetaCacheEntry` + snapshot/name key + REFRESH invalidation + authz gate), not universal code. Two members (manifest, PARTITIONS-scan) and the snapshot+schema pin are intrinsically iceberg-format-specific; three (table handle, comment, file-format) are format-neutral in intent and differ only in value type.
+
+---
+
+## 3. The pre-existing shared substrate `fe-connector-cache` — the natural generalization home
+
+Module `fe/fe-connector/fe-connector-cache/.../connector/cache/`:
+
+- **`CacheFactory`** — Caffeine factory; framework-internal (returns Caffeine types, which must never cross into child-first connector code). Connector-side copy of fe-core's `common.CacheFactory` (plugins can't import fe-core).
+- **`CacheSpec`** — the `(enable, ttl-second, capacity)` config value; `fromProperties(props, engine, entry, default)` reads `meta.cache...*`; `CACHE_TTL_DISABLE_CACHE`(0)/`CACHE_NO_TTL`(-1) sentinels. This is exactly the knob every Layer-1 cache uses.
+- **`MetaCacheEntry`** — the Caffeine-**free** unified cache-entry API: lazy/contextual loading, manual-miss-load (I/O outside Caffeine's lock, striped per-key dedup), key/predicate/full invalidation, and stats (`getLoadSuccessCount`, backing the `loadCountForTest` measurement gates). This is the shared engine under all six iceberg caches.
+- **`MetaCacheEntryStats`** — the stats/`isEffectiveEnabled` view.
+- **`ConnectorPartitionViewCache`** and **`PartitionViewCacheKey`** — an already-written **generic version of `IcebergPartitionCache`**: same contextual-only, manual-miss-load `MetaCacheEntry` shape, same `CacheSpec` wiring, but keyed by the engine-agnostic `PartitionViewCacheKey` with opaque value `V` and no engine imports. Its own javadoc states it "is the generic version of `IcebergPartitionCache`."
+
+**Who uses the substrate today** (verified imports/instantiations across `fe-connector/*/src/main/java`):
+- `MetaCacheEntry`: **hive** (`HiveFileListingCache`), **hms** (`CachingHmsClient`), **iceberg** (all six above), **maxcompute** (`MaxComputePartitionCache`), **paimon** (`PaimonLatestSnapshotCache`). **Not** jdbc / es / trino-connector (none).
+- `ConnectorPartitionViewCache` — actual `new …()` sites: **hive** `HiveConnector.java:136`, **iceberg** `IcebergConnector.java:281` & `:284` (mvcc view + list-partitions view), **paimon** `PaimonConnector.java:158`.
+
+**Is it the natural home to generalize Layer-1?** **Yes, and generalization is already underway.** The substrate exists, is Caffeine-free (safe across the loader split), reads a uniform `meta.cache...*` config namespace, and `ConnectorPartitionViewCache` is a *proof* that the exact iceberg pattern generalizes (it is `IcebergPartitionCache` with the key/value abstracted). The other five iceberg caches are structurally identical (all `new MetaCacheEntry<>(name, null, spec, commonPool, false, true, 0L, true)`), so `IcebergTableCache`/`IcebergCommentCache`/`IcebergFormatCache`/latest-snapshot could each collapse into a typed `ConnectorXViewCache` wrapper the same way.
+
+**One drift flag (Rule 7/12):** `ConnectorPartitionViewCache`'s class javadoc says "this class has **NO consumers yet**" — that is **stale at HEAD**: three connectors (hive, iceberg, paimon) already instantiate it. Worth a doc-only fix.
+
+---
+
+## 4. Layer 3 — authorization cache isolation under `iceberg.rest.session=user`
+
+The hazard: name-only-keyed caches would let a user who can `LIST` but not `LOAD` a table receive metadata a *different* user's `loadTable` produced ("list ≠ load" disclosure), because per-user authorization lives *inside* the delegated `loadTable` round-trip.
+
+**iceberg-REST-specific mechanisms:**
+- `IcebergConnector.isUserSessionEnabled()` (`:870-874`) = `rest.session == user` **and** flavor is REST.
+- Under it, the connector **nulls** its live cross-query caches in the constructor: latest-snapshot (`:231`), table (`:243`, also nulled for REST vended-creds), partition (`:254`), format (`:260`), and both `ConnectorPartitionViewCache` instances (`:279`, `:282`). `IcebergCommentCache` is *only* built for REST-vended-and-not-session-user (`:269`). So under `session=user` every shared cache is off → `beginQuerySnapshot`/reads go live per-user. Each field carries the reviewed marker `authz-cache-session-user-disabled` or `authz-cache-exempt` (enforced by gate §5).
+
+**fe-core-general mechanisms** (apply to *any* `SUPPORTS_USER_SESSION` connector, not just iceberg — iceberg-REST is simply the only one that currently declares the capability):
+- `ExternalCatalog.shouldBypassSchemaCache(SessionContext)` — base default `false` (`ExternalCatalog.java:367-369`); the generic name-keyed schema cache is bypassed live when true.
+- `PluginDrivenExternalCatalog` overrides it as `supportsUserSession() && ctx != null && ctx.hasDelegatedCredential()` (`:1212-1214`), with sibling `shouldBypassTableNameCache` (`:1190-1192`) and `shouldBypassDbNameCache` (`:1200-1202`) closing the same disclosure at the db-name/table-name-cache level. Consumed by `ExternalTable.java:439`.
+
+So Layer-3 is split: the *policy predicate* (`shouldBypass*`, capability+credential gated) is connector-general fe-core; the *cache-nulling* is iceberg-connector-local because that's where the caches live.
+
+---
+
+## 5. The two anti-drift build gates
+
+- **`tools/check-fecore-metadata-funnel.sh`** — **connector-GENERAL.** Fails the build on any executable `Connector#getMetadata()` in `fe/fe-core/src/main/java` outside the one legal carrier `datasource/plugin/PluginDrivenMetadata.java` (allow-list: the funnel file; a `getMetadata-funnel-exempt` marker; no-arg `getMetadata()`; comment lines). Invariant enforced: *one `ConnectorMetadata` per (statement, catalog) for every connector* — i.e. Layer 2. Its root is `fe/fe-core`, no iceberg specificity (lines 20-59, 67-105).
+- **`tools/check-authz-cache-sharding.sh`** — **iceberg-ONLY.** `TARGET_REL` is hard-coded to `fe-connector-iceberg/.../IcebergConnector.java` (line 57); it requires every `final …Cache[ <]` holder field there to carry `authz-cache-session-user-disabled` or `authz-cache-exempt` (lines 65-96). Invariant enforced: *no new shared cross-query cache on IcebergConnector without a declared `session=user` isolation discipline* — i.e. Layer 3, for iceberg. It explicitly notes the fe-core schema cache is a *different* cache protected separately by `shouldBypassSchemaCache` (lines 37-39). If a second connector ever declares `SUPPORTS_USER_SESSION`, this gate would need a new target — it does not generalize as written.
+
+---
+
+## 6. Bottom line
+
+| Framework piece | Already universal? |
+|---|---|
+| Per-statement `ConnectorMetadata` funnel (`PluginDrivenMetadata`, `ConnectorStatementScope`+`Impl`, `StatementContext` home, two-tier close) | **Yes** — connector-agnostic; live for all 7 `SPI_READY_TYPES` via 58 fe-core call sites; scope capture has no type gate |
+| `NONE` no-op scope + background-loader read-through | **Yes** — SPI default; used to shield cross-statement loaders |
+| `CatalogStatementTransaction` (one write txn per statement, ordered teardown) | **Yes** — fe-core-internal, neutral SPI types only |
+| `PluginDrivenScanNode` provider memo + per-node metadata cache (PERF-11 C14) | **Yes** — keyed on handle identity, no connector branch |
+| HMS-gateway sibling metadata memo (`memoizedSiblingMetadata`) | **Yes as mechanism (rides the universal scope)**, but **lives in the hive connector** and is keyed for the iceberg/hudi-on-HMS sibling case |
+| `fe-connector-cache` substrate (`CacheFactory`/`CacheSpec`/`MetaCacheEntry`/`Stats`) | **Yes** — shared; adopted by hive, hms, iceberg, maxcompute, paimon (not jdbc/es/trino) |
+| `ConnectorPartitionViewCache` / `PartitionViewCacheKey` (generic partition-view cache) | **Yes (universal by design)** — already instantiated by hive, iceberg, paimon; javadoc "no consumers yet" is stale |
+| The 6 iceberg caches — **table handle, comment, file-format** | **iceberg-only pattern**, format-neutral in spirit → the natural next candidates to fold into typed `ConnectorXViewCache` wrappers on the substrate |
+| The 6 iceberg caches — **PARTITIONS-scan, manifest, latest-snapshot(+schemaId)** | **iceberg-only pattern**, genuinely iceberg-format-specific |
+| Per-scan/per-file hoists (PERF-06 storage-uri normalizer, PERF-11 per-file invariant memo) | **iceberg-only** (inside `IcebergScanPlanProvider`); the SPI seam `ConnectorContext.newStorageUriNormalizer` is neutral but only iceberg consumes it |
+| Layer-3 policy predicate `shouldBypass{Schema,TableName,DbName}Cache` (capability+credential gated) | **Partial → connector-general in fe-core**, but only iceberg-REST currently declares `SUPPORTS_USER_SESSION`, so only iceberg exercises it |
+| Layer-3 cache-nulling under `isUserSessionEnabled()` | **iceberg-only** (the caches being nulled are iceberg-connector-local) |
+| Gate `check-fecore-metadata-funnel.sh` | **Yes — connector-general** (enforces Layer 2 across all of fe-core) |
+| Gate `check-authz-cache-sharding.sh` | **iceberg-only** (hard-coded to `IcebergConnector.java`; would need extension for any second user-session connector) |
+
+**Net:** the *engine seam* (Layer 2 + its gate + the `fe-connector-cache` substrate + the generic partition-view cache + the general `shouldBypass*` predicate) is already universal and load-bearing for every plugin connector. What is iceberg-specific is the *filling*: the six concrete caches (three format-specific, three format-neutral-but-not-yet-generalized), the per-scan hoists, the cache-nulling, and the second gate. The substrate to universalize the format-neutral half already exists and is already adopted by three connectors.
\ No newline at end of file
diff --git a/plan-doc/connector-cache-unification/appendix-B-trino-reference.md b/plan-doc/connector-cache-unification/appendix-B-trino-reference.md
new file mode 100644
index 00000000000000..3c736e0ec603e1
--- /dev/null
+++ b/plan-doc/connector-cache-unification/appendix-B-trino-reference.md
@@ -0,0 +1,140 @@
+I have everything grounded. Returning the analysis.
+
+---
+
+# Trino connector-metadata caching & per-transaction lifecycle — mapped onto the Doris catalog-SPI framework
+
+Scope note: every Doris claim below is hung on HEAD (`branch-catalog-spi`, reference commit `0b4f72582e7`) with `file:line`. Trino claims are grounded in the actual current source I fetched (`InMemoryTransactionManager`, `CatalogMetadata`, `CatalogTransaction`, `SharedHiveMetastoreCache`); where I rely on architectural knowledge rather than a fetched line I say so.
+
+---
+
+## 1. Trino's per-transaction metadata lifecycle, and how faithful the Doris Layer-2 funnel is
+
+### 1a. What Trino actually does
+
+The lifecycle unit in Trino is the **transaction**, not the session and not the statement. Three layers cooperate:
+
+- **`InMemoryTransactionManager.TransactionMetadata`** holds `activeCatalogs`, a `ConcurrentHashMap`. `getTransactionCatalogMetadata(catalogHandle)` lazily does `catalog.beginTransaction(...)` on first touch of a catalog and caches the resulting `CatalogMetadata` for the transaction; `asyncCommit()`/`asyncAbort()` iterate `activeCatalogs.values()` and `commit`/`abort` each exactly once. (fetched from `InMemoryTransactionManager.java`.)
+- **`CatalogMetadata`** wraps up to three `CatalogTransaction`s (the normal catalog, `information_schema`, system tables). `getMetadata(session)` returns the normal one; `getMetadataFor(session, handle)` routes by handle. (fetched from `CatalogMetadata.java`.)
+- **`CatalogTransaction`** is where the one-metadata-per-transaction guarantee physically lives:
+ ```
+ @GuardedBy("this") private ConnectorMetadata connectorMetadata;
+ synchronized ConnectorMetadata getConnectorMetadata(Session session) {
+ if (connectorMetadata == null) {
+ ConnectorSession cs = session.toConnectorSession(catalogHandle);
+ connectorMetadata = connector.getMetadata(cs, transactionHandle);
+ }
+ return connectorMetadata;
+ }
+ ```
+ and `commit()`/`abort()` are gated by an `AtomicBoolean finished` so the connector transaction finishes once. (fetched from `CatalogTransaction.java`.)
+
+Two structural properties fall out of this and matter for Doris:
+
+1. **The `ConnectorMetadata` instance lives for the whole transaction and is the memo home.** Because Trino hands the connector one long-lived instance per transaction, connectors keep per-transaction working state *on that instance* (Iceberg's `IcebergMetadata` retains loaded tables/statistics; Hive's metadata holds a per-transaction metastore view). Repeated `resolveTable`/`getTableStatistics` within the transaction therefore collapse **for free** — the engine never needs a separate "load once per statement" seam.
+2. **`getMetadata(session, transactionHandle)`** — the transaction handle is woven into the metadata at creation, so read and write are the same transaction by construction. `MetadataManager` (the engine's façade) routes every metadata call to `transactionManager.getCatalogMetadata(...).getMetadata(session)`, i.e. always through this cached instance.
+
+### 1b. What Doris does, and whether the emulation is sound
+
+Doris cannot use the session as the memo home, and the framework is explicit about why:
+
+- `ConnectorSessionImpl` is **immutable** and is **rebuilt** at each seam (`ConnectorSessionBuilder`); the PR cites ~26 rebuilds/statement. I confirmed the load-bearing fact — the session is immutable (`ConnectorSessionImpl.java:36`, all fields `final`) and constructed on demand rather than retained — so it *cannot* carry mutable per-statement memo. I did not independently count 26.
+- Instead the memo hangs on the one **`StatementContext`**: `getOrCreateConnectorStatementScope()` (`StatementContext.java:656`) lazily builds a `ConnectorStatementScopeImpl` (`StatementContext.java:209` field). The scope is a `ConcurrentHashMap` (`ConnectorStatementScopeImpl.java:44`).
+- Each session **captures the scope reference at build time** (`ConnectorSessionBuilder.captureStatementScope()` :190–203) so off-thread scan pumps that reuse one session still reach the same scope even without a `ConnectContext` thread-local — the direct analog of Trino handing the same `CatalogTransaction` to every operation on the transaction.
+- The funnel is **`PluginDrivenMetadata.get(session, connector)`** (`PluginDrivenMetadata.java:53`), which memoizes `connector.getMetadata(session)` under key `"metadata:"+catalogId` on the scope (:70). This is the Doris analog of `CatalogTransaction.getConnectorMetadata`. It is the *only* place fe-core may call `Connector#getMetadata` — enforced build-wide by `tools/check-fecore-metadata-funnel.sh` (present, executable). I count **59** call sites in fe-core routed through it.
+
+**Assessment — is hanging the memo on `StatementContext` (not the session) sound?** Yes, with two caveats worth stating plainly:
+
+- **Sound mechanically.** The session is the wrong home precisely because it is immutable and rebuilt; the `StatementContext` is the one object that lives exactly as long as the statement and is reachable from every seam. The `ConcurrentHashMap` + `computeIfAbsent` gives every caller of a key the same instance (required because scan and write both mutate the shared table/delete-supply). Teardown is deterministic and idempotent: `closeAll()` (`ConnectorStatementScopeImpl.java:65`) runs two ordered passes (finalize any `CatalogStatementTransaction`, then close every `AutoCloseable` metadata), fired from the `getSplits` query-finish callback (`PluginDrivenScanNode.java:1237`) with a `StatementContext.resetConnectorStatementScope()` fallback (`StmtExecutor.java:1077`, `ExecuteCommand.java:95`). This mirrors Trino's `activeCatalogs.values()` commit/abort sweep + `AtomicBoolean finished`.
+- **Caveat 1 — statement ≠ transaction.** Trino's unit is the whole `BEGIN…COMMIT` block; a multi-statement Trino transaction shares one `ConnectorMetadata` (and one connector transaction) across all its statements. Doris's unit is **one statement** (the scope key includes `queryId`, e.g. `IcebergStatementScope.java:64`), and a prepared-statement `EXECUTE` explicitly *drops* the scope per execution (`resetConnectorStatementScope`). For external-catalog reads and autocommit external DML (iceberg/hive) this is equivalent, because each such statement is its own transaction. But the read-consistency-within-a-transaction guarantee Trino gives across a multi-statement block is, in Doris, only a within-*statement* guarantee. Fine today; document it so nobody assumes cross-statement metadata sharing.
+- **Caveat 2 — the metadata carries no transaction handle.** Trino's `getMetadata(session, transactionHandle)` bakes the transaction in; Doris's `getMetadata(session)` does not, and recovers write-transaction coupling with a *separate* co-holder (`CatalogStatementTransaction`, see §4) plus a **fail-loud identity pin** (`PluginDrivenMetadata.java:63–69`): the memo records the building user and throws if a second identity reuses the instance. Trino never needs that check because a transaction is single-identity by construction; Doris adds it because one shared singleton connector serves all users and a `session=user` metadata bakes in a delegated credential. This is a reasonable adaptation, not a defect.
+
+**Net:** the emulation is faithful in shape (lazy one-metadata-per-(statement,catalog), deterministic teardown, single routing funnel, arch-gate-enforced) and the choice to hang it on `StatementContext` is the correct — indeed the only — option given Doris's immutable-rebuilt session.
+
+---
+
+## 2. Cross-transaction connector caches: the long-lived-shared vs per-transaction-view split
+
+### 2a. Trino
+
+Trino keeps a **long-lived shared cache** separate from a **per-transaction view**, per connector:
+
+- **Metastore.** `CachingHiveMetastore` is the shared, TTL-bounded cache (`getTable`, `getPartition(s)`, `getTableStatistics`/`getPartitionStatistics`, `listPartitionNames`). `SharedHiveMetastoreCache.ImpersonationCachingHiveMetastoreFactory` holds a `LoadingCache` **keyed by identity user** (`cache.getUnchecked(identity.getUser())`), configured `expireAfterWrite(userCacheTtl)` + `maximumSize(userCacheMaximumSize)` — i.e. under impersonation the cache is **sharded per identity**, not shared across users (fetched from `SharedHiveMetastoreCache.java`; corroborated by PR #9482 "add impersonation to SharedHiveMetastoreCache and remove per-user metadata caching from CachingHiveMetastore"). Over that shared cache Trino layers a **per-transaction `CachingHiveMetastore`** (the `createPerTransactionCache`/memoize wrapper) so that within one transaction repeated reads are consistent and never re-hit the metastore.
+- **Directory listing** is a *separate* cache layer: `CachingDirectoryLister` (shared) with `TransactionScopeCachingDirectoryLister` giving the per-transaction consistent view — deliberately not folded into the metastore cache.
+- **Iceberg** caches the resolved `Table` in the `TrinoCatalog` implementations (a Caffeine `Cache` gated by the `iceberg.metadata-cache` / expiration config), and `IcebergMetadata` additionally holds per-transaction working state on the instance.
+- **Invalidation** is TTL + explicit: `CachingHiveMetastore.invalidateTable(...)` is called around writes so a write *within the transaction* invalidates the relevant entries; the per-transaction wrapper prevents a stale read of the just-written table.
+
+### 2b. Doris equivalents at HEAD
+
+Doris re-homed exactly this shape inside the connectors (the SPI cutover had left plain-hive caching nothing once a catalog became plugin-driven):
+
+| Trino | Doris (HEAD) |
+|---|---|
+| `CachingHiveMetastore` (metastore RPCs) | `CachingHmsClient` — decorates `HmsClient`, caches `getTable`/`listPartitionNames`/`getPartitions`/`getTableColumnStatistics`, each on its own `MetaCacheEntry`, keyed to Trino/legacy shape; class doc explicitly cites "Trino `CachingHiveMetastore` shape" (`fe-connector-hms/.../CachingHmsClient.java`) |
+| `CachingDirectoryLister` / `TransactionScopeCachingDirectoryLister` | `HiveFileListingCache` — memoizes `FileSystem.listStatus` per `(db,table,location)` on a `MetaCacheEntry`; doc cites "Trino keeps the equivalent `CachingDirectoryLister` as a layer separate from its metastore cache" (`fe-connector-hive/.../HiveFileListingCache.java`) |
+| Iceberg `TrinoCatalog` table cache + metadata memo | Two-level: cross-query `IcebergTableCache` (`getOrLoad`, `IcebergTableCache.java:86`) **plus** per-statement `IcebergStatementScope.sharedTable` (`IcebergStatementScope.java:59`), reached from `IcebergScanPlanProvider.resolveTable` (:2353 → `loadRawTable` :2378) |
+| Iceberg partition/format/comment/manifest/snapshot metadata caches | `IcebergPartitionCache`, `IcebergFormatCache`, `IcebergCommentCache`, `IcebergManifestCache`, `IcebergLatestSnapshotCache`, plus the generic derived `ConnectorPartitionViewCache` (`IcebergConnector.java:169–203`) |
+
+**How Doris splits shared vs per-statement:** the cross-query caches live as `final` fields on the long-lived per-catalog connector (rebuilt only by `REFRESH CATALOG`), and the per-statement view is the `StatementContext` scope. For iceberg this is a genuine two-level split (`sharedTable` over `IcebergTableCache`) that matches Trino's per-transaction-view-over-shared-cache. For hive, `CachingHmsClient`/`HiveFileListingCache` are shared-only; the per-statement view is only the single `ConnectorMetadata` the funnel memoizes.
+
+**Invalidation** is coarser than Trino by design: Doris uses TTL + `REFRESH TABLE/DATABASE/CATALOG` flush (`IcebergTableCache.invalidate/invalidateDb/invalidateAll`; `CachingHmsClient.flush/flushDb/flushAll`). The `CachingHmsClient` doc states it **does not self-invalidate around writes** ("coarse REFRESH + TTL bound staleness"). Trino *does* invalidate within the transaction around a write. **Gap:** a Doris-side write leaves a TTL-bounded stale window for a subsequent read of the same table (no read-your-write within the coarse cache). Recommendation: either add write-path invalidation to `CachingHmsClient` or force the post-write read through a live (scope-`NONE` / bypass) read, matching Trino's per-transaction invalidation.
+
+**Authorization.** This is the sharpest divergence. Trino, under impersonation, **still caches but shards the cache key by identity** (`LoadingCache`). Doris, under `iceberg.rest.session=user`, **disables the caches entirely** — `latestSnapshotCache`/`tableCache`/`partitionCache`/`formatCache`/`commentCache` are set `null` under `isUserSessionEnabled()` (`IcebergConnector.java:231–273`), and fe-core adds `shouldBypassSchemaCache(SessionContext)` so schema is re-read live per user. This is enforced by `tools/check-authz-cache-sharding.sh` (every cache field must carry `authz-cache-session-user-disabled` or `authz-cache-exempt`). It is *safe* (it closes the "can-LIST-cannot-LOAD" metadata-disclosure the name-only keys would leak) but it is **coarser and slower** than Trino: `session=user` catalogs get no cross-query metadata cache at all. Recommendation (future): follow Trino and **key the caches by identity** rather than disabling them, restoring caching under `session=user`. That is a larger change (every key gains a user dimension) and the current "disable" is the correct conservative first cut.
+
+---
+
+## 3. The unification question — does Trino have one generic connector cache, or per-connector caches on a shared toolkit?
+
+**Trino has no single unified connector cache.** It has a shared **low-level toolkit** — `io.trino.cache` (`EvictableCacheBuilder`, `SafeCaches`, `NonEvictableCache`/`NonKeyEvictableLoadingCache`, `CacheStatsMBean`) over Guava/Caffeine — and each connector builds *its own* caches on top: `CachingHiveMetastore`, `CachingJdbcClient`, the Iceberg catalog `tableCache`, `CachingDirectoryLister`. The reused unit is the *builder + eviction-safety + stats* machinery, not a cache and not a cache key model. This is deliberate: the cached objects and their key/invalidation/authorization semantics differ irreducibly per connector (an iceberg `Table` is not an `HmsTableInfo` is not a paimon `Table`).
+
+**Doris is already on this Trino-correct path.** `fe-connector-cache` is exactly the shared toolkit: `MetaCacheEntry` (Caffeine encapsulated behind a Caffeine-free API, striped single-flight locks, manual-miss-load, stats — `MetaCacheEntry.java`), `CacheSpec`, `CacheFactory`, and the one genuinely generic reusable *cache* `ConnectorPartitionViewCache`/`PartitionViewCacheKey`. Adopters confirmed by import: **hive, hms, iceberg, maxcompute, paimon** all build on `MetaCacheEntry`/`CacheSpec`/`ConnectorPartitionViewCache`; the only direct-Caffeine use in a connector is `org/apache/iceberg/DeleteFileIndex.java`, which is vendored iceberg code, not a Doris cache.
+
+**Lesson for the "unify the connector cache framework" goal:**
+
+1. **Do not build a single connector-agnostic metadata cache.** Trino proves the right seam is the toolkit, because the cached value types and their invalidation/authz rules are per-connector. Doris already has that toolkit (`MetaCacheEntry`) and it is already broadly adopted. Unifying *harder* than Trino (one cache to rule them all) would fight the same semantics Trino refused to fight.
+2. **The real unification opportunity Doris is missing is not the caches — it is the per-statement dedup.** Trino gets "load each table once per transaction" *for free* because the `ConnectorMetadata` instance is the transaction-lived memo home (§1a-1). Doris's funnel guarantees one `ConnectorMetadata` per statement, but whether repeated `resolveTable` within that statement collapses is **per-connector and not uniform** — see §5. Iceberg routes table loads through the statement scope (`IcebergStatementScope.sharedTable`); the others do not. The highest-value "unification" is a shared **"resolve table via the statement scope"** helper so every connector inherits one-load-per-statement, closing the Variant-B gap uniformly instead of connector-by-connector.
+3. **Converge the last bespoke caches onto `MetaCacheEntry`** — notably paimon still lacks a cross-query raw-`Table` cache (§5), and jdbc/es keep their own structures.
+
+---
+
+## 4. Write-path consistency — read + write sharing one metadata / one transaction
+
+**Trino.** Read and write share the identical `ConnectorMetadata` for the transaction (the single `CatalogTransaction.connectorMetadata`): `beginInsert`/`finishInsert`/`beginMerge`/`finishMerge` are invoked on the same instance the reads used, and the connector transaction is finished by `CatalogTransaction.commit()`/`abort()` on the same `transactionHandle`. The write therefore sees exactly the reads' snapshot; there is one handle, one metadata, one commit.
+
+**Doris (HEAD).** The framework copies this "one-metadata-one-write-transaction-per-statement" model deliberately:
+
+- The **8 write-path `getMetadata` seams are routed through the same `PluginDrivenMetadata` funnel**, so read and write share the one memoized `ConnectorMetadata`, guarded by the fail-loud identity pin (`PluginDrivenMetadata.java:63–69`) — a `session=user` metadata baking in one user's delegated credential can never be silently reused to execute another user's write.
+- The write transaction is hoisted into **`CatalogStatementTransaction`** (`CatalogStatementTransaction.java`), co-held on the scope next to the shared metadata. `begin(writeHandle)` mints the connector transaction from the **shared metadata's own write facet** (`writeOps.beginTransaction(session, writeHandle)`, :81) and registers it in `PluginDrivenTransactionManager`, so the write inherits exactly the read arm's client/ops — the class doc explicitly names this "mirroring Trino's `CatalogTransaction`: one metadata instance and one transaction per (statement, catalog)."
+- Teardown is the deterministic two-pass in `closeAll()`: pass 1 `finalizeAtStatementEnd()` rolls back a transaction the executor never committed (only a mid-flight abort leaves one active), pass 2 closes the shared metadata — transaction is always finished **before** the instance it was minted from is closed (`ConnectorStatementScopeImpl.java:70–96`, `CatalogStatementTransaction.java:100`). `finalizeAtStatementEnd` is a no-op on every normal path (the executor already finished the txn, so the manager no longer holds it), and can never undo a committed write.
+
+**Faithfulness / gaps:**
+- Faithful in the essential: read and write provably share one metadata and one connector transaction, torn down in the right order, with a fail-loud cross-identity guard Trino gets structurally.
+- **Narrower scope than Trino:** the co-holder is per-*statement* (per `queryId` scope), so a *multi-statement* Doris transaction that mixes external writes and reads does not share one metadata/txn across statements the way a Trino `BEGIN…COMMIT` does. For Doris's autocommit external DML (iceberg/hive) this is equivalent; flag it if multi-statement external transactions are ever targeted.
+
+---
+
+## 5. Summary table — Trino mechanism | Doris equivalent today | gap / recommendation
+
+| Trino mechanism | Doris equivalent at HEAD | Gap / recommendation |
+|---|---|---|
+| **Transaction is the lifecycle unit**; `TransactionManager.activeCatalogs: Map`, lazy `beginTransaction`, commit/abort sweep | **Statement** is the unit; `PluginDrivenMetadata.get` memoizes `ConnectorMetadata` per `(statement,catalog)` on `ConnectorStatementScopeImpl` (scope keyed incl. `queryId`); `closeAll` two-pass teardown | Sound. Narrower: no metadata sharing across a multi-statement `BEGIN…COMMIT`. Fine for autocommit external DML; **document the statement-not-transaction scope.** |
+| `CatalogTransaction.connectorMetadata` (`@GuardedBy(this)`, lazy `connector.getMetadata(session, txnHandle)`) — **the instance is the per-txn memo home**, so repeated table/stat resolves collapse for free | Funnel gives **one `ConnectorMetadata` per statement**, but per-statement *table-load* dedup is only guaranteed where the connector routes through the scope | **Non-uniform.** Only iceberg uses the scope for table load (`IcebergStatementScope.sharedTable`). **Paimon** re-resolves via a transient `Table` on the handle (`PaimonTableResolver.resolve` → `handle.getPaimonTable()`; branch/withScanOptions handles carry `null` → live re-load) and has **no cross-query raw-`Table` cache** (only `PaimonLatestSnapshotCache` + `schemaAtMemo`). Recommend a **shared "resolve table via statement scope" helper** so every connector gets one-load-per-statement. |
+| `getMetadata(session, ConnectorTransactionHandle)` — txn handle woven into the metadata | `getMetadata(session)` — no txn handle; write txn is a **separate** `CatalogStatementTransaction` co-holder + identity pin | Acceptable adaptation; co-holder + `PluginDrivenMetadata.java:63–69` identity pin recover the guarantee Trino gets structurally. Keep. |
+| `CachingHiveMetastore` (shared, long-lived; getTable/partitions/stats) | `CachingHmsClient` (getTable/listPartitionNames/getPartitions/getTableColumnStatistics on `MetaCacheEntry`) | Faithful shape. **Gap:** no write-path self-invalidation ("coarse REFRESH+TTL"); Trino invalidates within the txn. **Add write-path invalidation or a bypass read for read-your-write.** |
+| **Per-transaction** `CachingHiveMetastore` wrapper over the shared cache (read consistency within a txn) | Shared `CachingHmsClient`/`HiveFileListingCache` with **no per-statement scoped view** over them (only the single memoized metadata) | Minor: within one statement a re-list/re-get could observe concurrent change. Low priority (TTL bounds it); add a statement-scoped consistent view if strict intra-statement consistency is needed. |
+| `SharedHiveMetastoreCache` shards the cache **per identity** under impersonation (`LoadingCache`, `expireAfterWrite`) | Under `iceberg.rest.session=user`, caches are **nulled** (`IcebergConnector.java:231–273`) + fe-core `shouldBypassSchemaCache` | Safe but coarse: `session=user` gets **no** cross-query cache. **Recommend key-by-identity sharding** (Trino model) to restore caching under `session=user`; current disable is the correct conservative first cut. |
+| `CachingDirectoryLister` (+ `TransactionScopeCachingDirectoryLister`), **separate** from the metastore cache | `HiveFileListingCache` on `MetaCacheEntry`, separate layer from `CachingHmsClient` | Faithful (doc cites the Trino split). No per-transaction consistency view (see above). |
+| Iceberg `TrinoCatalog` Caffeine `tableCache` + `IcebergMetadata` per-txn memo | **Two-level**: cross-query `IcebergTableCache` + per-statement `IcebergStatementScope.sharedTable` | Faithful, arguably more explicit than Trino. Good — this is the reference the other connectors should copy. |
+| `io.trino.cache` toolkit (`EvictableCacheBuilder`/`SafeCaches`/`CacheStatsMBean`) reused by all connectors; **no single unified cache** | `fe-connector-cache`: `MetaCacheEntry`/`CacheSpec`/`CacheFactory`/`ConnectorPartitionViewCache`, reused by hive/hms/iceberg/maxcompute/paimon | **Already Trino-correct.** Unify at the *toolkit*, not one cache. Remaining work: converge paimon's raw-table load and jdbc/es onto `MetaCacheEntry`; unify **per-statement dedup**, not the caches. |
+| Per-txn teardown: `activeCatalogs` commit/abort iterate; `CatalogTransaction.finished` (`AtomicBoolean`) | `closeAll()` two-pass (finalize `CatalogStatementTransaction`, then close metadata), idempotent, fired from query-finish callback + prepared-stmt reset | Faithful. Good. |
+| Single identity per transaction (structural) → no cross-user metadata reuse possible | Shared singleton connector serves all users → **explicit** fail-loud identity pin in the funnel + `check-authz-cache-sharding.sh` | Doris must assert what Trino gets for free; the guards are the right shape. Keep enforced. |
+
+### Bottom line for the redesign
+
+The Doris framework is a faithful, deliberately-Trino-shaped emulation: the funnel = `CatalogTransaction`, `CatalogStatementTransaction` = the one-metadata-one-transaction write model, `CachingHmsClient`/`HiveFileListingCache`/`IcebergTableCache` = `CachingHiveMetastore`/`CachingDirectoryLister`/iceberg catalog cache, and `fe-connector-cache` = the `io.trino.cache` toolkit. The three places it diverges from Trino are all worth tracking:
+
+1. **Per-statement table dedup is non-uniform** — Trino gets it for free from the transaction-lived metadata instance; Doris has it only for iceberg (via the scope). **This is the single highest-value generalization** and the true meaning of "unify the connector cache framework": a shared statement-scope table-resolution helper, not a shared cache. Paimon is the concrete offender (transient-on-handle only, no cross-query table cache).
+2. **Write-path cache invalidation** — Doris relies on coarse REFRESH+TTL where Trino invalidates within the transaction (read-your-write gap).
+3. **`session=user` authorization** — Doris *disables* caches where Trino *shards by identity*; correct-but-slow, and the natural next step is Trino-style per-identity keying.
+
+Sources: [InMemoryTransactionManager](https://github.com/trinodb/trino/blob/master/core/trino-main/src/main/java/io/trino/transaction/InMemoryTransactionManager.java), [CatalogMetadata](https://github.com/trinodb/trino/blob/master/core/trino-main/src/main/java/io/trino/metadata/CatalogMetadata.java), [CatalogTransaction](https://github.com/trinodb/trino/blob/master/core/trino-main/src/main/java/io/trino/metadata/CatalogTransaction.java), [SharedHiveMetastoreCache](https://github.com/trinodb/trino/blob/master/lib/trino-metastore/src/main/java/io/trino/metastore/cache/SharedHiveMetastoreCache.java), [PR #9482 (impersonation in SharedHiveMetastoreCache)](https://github.com/trinodb/trino/pull/9482).
\ No newline at end of file
diff --git a/plan-doc/connector-cache-unification/appendix-C-funnel-universality.md b/plan-doc/connector-cache-unification/appendix-C-funnel-universality.md
new file mode 100644
index 00000000000000..3dd487052a1f00
--- /dev/null
+++ b/plan-doc/connector-cache-unification/appendix-C-funnel-universality.md
@@ -0,0 +1,77 @@
+All evidence is grounded. Here is the report.
+
+---
+
+# Verdict on the CLAIM
+
+**CLAIM:** "The per-statement `ConnectorMetadata` funnel (Layer 2) already routes EVERY plugin connector, so all connectors already share one metadata per statement — the only per-connector gap is whether each connector's own `ConnectorMetadata` memoizes its heavy loads."
+
+**Verdict: CONFIRMED on the routing half; DIRECTIONALLY CORRECT but imprecise on the "memoizes its heavy loads" half.**
+
+- **Half 1 (routing) — TRUE.** The funnel `PluginDrivenMetadata.get(ConnectorSession, Connector)` is fully connector-agnostic: it keys the memo on `catalogId` only and has **no connector-type branch** (`PluginDrivenMetadata.java:53-70`). Every fe-core seam — read, scan, DDL, MVCC, write/insert — acquires metadata through it, for all 8 SPI types (`jdbc, es, trino-connector, max_compute, paimon, iceberg, hms`, + hudi as an HMS-gateway sibling). Enforced build-wide by `tools/check-fecore-metadata-funnel.sh` (gate passes, `exit=0`; **0** remaining exempt markers).
+
+- **Half 2 (the gap) — needs correction.** It is true that the open per-connector question is memoization. But the CLAIM's phrase "each connector's *own* `ConnectorMetadata` memoizes" is **misleading: no connector stores the loaded table as a `ConnectorMetadata` instance field.** The load-collapsing lives either **one level up** (iceberg → the per-statement scope; hive/hudi/paimon → connector-owned cross-query caches) or **one level down** (paimon/maxcompute/trino → fat handle). Consequently, "one metadata per statement" translates into "one remote load per statement" **only for Iceberg** — the one connector that binds the memo to `session.getStatementScope()`. For every other connector the collapse happens at a *different granularity* (per-handle, or cross-query TTL), which does **not** guarantee one-load-per-statement.
+
+---
+
+## 1. The funnel is connector-general and covers all seams
+
+`PluginDrivenMetadata.get` (`fe/fe-core/.../datasource/plugin/PluginDrivenMetadata.java:53-70`) memoizes `connector.getMetadata(session)` on the statement scope keyed by `"metadata:" + catalogId`; the only conditional is a fail-loud **identity** guard (`getUser` mismatch), not a connector-type branch.
+
+| Seam | Callsites through the funnel (file:line) |
+|---|---|
+| Read path | `PluginDrivenExternalTable.java:133,159,189,449,565,583,606,716,820,881,923,954,1025,1060,1126,1277` |
+| Scan planning | `PluginDrivenScanNode.java:206,222` |
+| DDL (catalog/table) | `PluginDrivenExternalCatalog.java:296,312,333,423,502,551,599,671,715,808…1019,1107,1113`; `ConnectorExecuteAction.java:129`; `CallExecuteStmtFunc.java:102` |
+| MVCC | `PluginDrivenMvccExternalTable.java:148,363,782` |
+| Write / insert | `PluginDrivenInsertExecutor.java:219`; `BindSink.java:674,713`; `PhysicalExternalRowLevelMergeSink.java:289`; `IcebergRowLevelDmlTransform.java:124` |
+| Translation / TVF / SHOW | `PhysicalPlanTranslator.java:613,661`; `MetadataGenerator.java:1266`; `JdbcQueryTableValueFunction.java:53`; `ShowPartitionsCommand.java:286` |
+
+**Connector-type branch inside the funnel: NONE.** The only `switch(catalogType)` in the plugin classes (`PluginDrivenExternalTable.java:1211-1258`) is purely cosmetic — user-visible engine names for `SHOW TABLE STATUS`/`information_schema` — not metadata routing. The one heterogeneous case is the **HMS gateway**, which keys its *sibling* memo by owner label inside `fe-connector-hive` (`HiveConnectorMetadata.memoizedSiblingMetadata:302`), not in fe-core.
+
+## 2. What the gate enforces — connector-general
+
+`tools/check-fecore-metadata-funnel.sh` greps **any** `.getMetadata()` under `fe/fe-core/src/main/java` and fails the build unless it is (1) inside `PluginDrivenMetadata.java` itself, (2) marked `getMetadata-funnel-exempt`, (3) a no-arg `getMetadata()`, or (4) a comment. It is entirely connector-agnostic (matches the SPI call *form*, no connector names). Currently **0** exempt markers remain — the write arm is fully funneled — and the gate exits 0.
+
+## 3. Per-connector funnel-memoization table
+
+The funnel guarantees **one `ConnectorMetadata` instance per statement per catalog** for all connectors. Whether that collapses repeated remote loads to one-per-statement depends on where each connector puts its memo:
+
+| Connector | Funneled? | Does its ConnectorMetadata collapse repeated table loads to **one per statement**? | Mechanism & evidence (file:line) |
+|---|---|---|---|
+| **Iceberg** (ref) | Yes | **YES** — the only true per-statement collapse | `resolveTableForRead` routes through `IcebergStatementScope.sharedTable(session,…)` = `session.getStatementScope().computeIfAbsent(key, loader)` (`IcebergStatementScope.java:59-66`; `IcebergConnectorMetadata.java:644-650`). Plus cross-query `IcebergTableCache` (`:646-648`). One `loadTable` per statement even across distinct `getTableHandle` calls. |
+| **Paimon** | Yes | **PARTIAL** — per-*handle*, not per-statement | Fat handle: `getTableHandle` loads + `handle.setPaimonTable(table)` (`PaimonConnectorMetadata.java:211,221`); `resolveTable` prefers `handle.getPaimonTable()` (`PaimonTableResolver.java:63-67`). No statement-scope memo → each *fresh* `getTableHandle` re-issues `catalogOps.getTable`; only paimon's own cross-query `CachingCatalog` (TTL `meta.cache.paimon.table.ttl-second`) dedups across handles. |
+| **MaxCompute** | Yes | **PARTIAL** — per-*handle* | Fat handle carries the ODPS `Table`: `getTableHandle`→`getOdpsTable` into handle (`MaxComputeConnectorMetadata.java:124-129`); `getTableSchema` reads `mcHandle.getOdpsTable()` (`:136`). ODPS SDK lazily loads+caches schema on that `Table` object; no statement/instance memo across separate `getTableHandle` calls. |
+| **Trino** | Yes | **PARTIAL** — per-*handle*, and re-opens a txn | `getTableHandle` eagerly bakes trino handle + column-handle/metadata maps into `TrinoTableHandle` (`TrinoConnectorDorisMetadata.java:166-179`). `getTableSchema` reuses the handle's `columnHandleMap` (`:200`) but **re-opens a fresh trino transaction and re-calls `getColumnMetadata` per column** (`:194-209`). Each `getTableHandle`/`getTableSchema` = a new `beginTransaction`. |
+| **Hive** | Yes | **NO instance/statement memo** — relies on connector-owned cross-query cache | `getTableHandle` (`HiveConnectorMetadata.java:399`) and `getTableSchema` (`:493`) each call `hmsClient.getTable(db,table)`. Collapse lives in the cross-query `CachingHmsClient` decorator keyed by `(db,table)` (`CachingHmsClient.java:53`), owned by the long-lived connector, TTL `meta.cache.hive.*`. Cache-disabled ⇒ live thrift RPC per call; no per-statement guarantee. |
+| **Hudi** | Yes (HMS sibling) | **NO** | HMS part cached via `CachingHmsClient` (`getTableHandle`→`hmsClient.getTable`, `HudiConnectorMetadata.java:207`), but the schema read **rebuilds `HoodieTableMetaClient` and `reloadActiveTimeline()` on every** `getSchemaFromMetaClient` call (`:797-806`). No memo of the metaClient/schema. |
+| **Jdbc** | Yes | **NO** | `getTableHandle` is a lightweight existence check (`JdbcConnectorMetadata.java:102`). `getTableSchema`→`client.getJdbcColumnsInfo` runs a live JDBC `DatabaseMetaData` column query with **no cache** (`JdbcClient.getJdbcColumnsInfo`, `JdbcClient.java:395`). (Normally served by fe-core's cross-statement schema cache, not the metadata instance.) |
+| **Es** | Yes | **NO** | `getTableSchema`→`restClient.getMapping(index)` per call (`EsConnectorMetadata.java:85`); `getColumnHandles` re-invokes `getTableSchema` (`:99`). No memo. |
+
+## 4. The "9 cross-statement background loaders forced through NONE-scope" — connector-general
+
+They route through `PluginDrivenExternalCatalog.buildCrossStatementSession()` (`:1153-1170`), which forces `ConnectorStatementScope.NONE`. Exactly **9** callers, all in connector-agnostic fe-core base classes (no connector-type branch):
+
+1. `PluginDrivenExternalTable.initSchema` (`:448`) — schema cache
+2. `PluginDrivenExternalTable.getColumnStatistic` (`:1024`) — column-stat cache
+3. `PluginDrivenExternalTable.getChunkSizes` (`:1059`)
+4. `PluginDrivenExternalTable.fetchRowCount` (`:1125`) — row-count cache (the fix for `ANALYZE`'s synchronous `fetchRowCount` capturing a live statement scope)
+5. `PluginDrivenExternalCatalog.listDatabaseNames` (`:295`) — db-name cache
+6. `PluginDrivenExternalCatalog.listTableNamesFromRemote` (`:311`) — table-name cache
+7. `PluginDrivenExternalCatalog.fromRemoteDatabaseName` (`:1106`)
+8. `PluginDrivenExternalCatalog.fromRemoteTableName` (`:1112`)
+9. `MetadataGenerator.dealPluginDrivenCatalog` (`:1265`) — the BE-driven metadata TVF
+
+Under `NONE` the funnel memoizes nothing (`PluginDrivenMetadata.java:62`; `ConnectorSessionBuilder.captureStatementScope:196,200`), so these fill caches that outlive any statement without ever binding into (or being closed with) a live statement's scope. **Connector-general, not iceberg-specific.**
+
+---
+
+## Surprises / corrections to the CLAIM
+
+1. **No connector memoizes on the `ConnectorMetadata` instance itself.** The CLAIM's mental model ("each connector's own ConnectorMetadata memoizes its heavy loads") does not match the code: the memo is always one level up (iceberg statement scope; hive/hudi/paimon connector-level cross-query caches) or one level down (paimon/maxcompute/trino fat handle). "One metadata per statement" is therefore **necessary but not sufficient** — a shared metadata instance with no memo still re-loads.
+
+2. **Only Iceberg realizes the funnel's promise (one load per statement).** It is the sole connector that ties the collapse to `session.getStatementScope()`. Every other connector's "collapse" is either per-handle (breaks across distinct `getTableHandle` calls from different fe-core entrypoints — e.g. `initSchema`, `listPartitions`, `getColumnStatistics`, scan planning each re-resolve a handle) or cross-query TTL (breaks when the connector's cache is disabled). So the per-connector gap is not merely "add a cache" — for parity with iceberg they would need a **statement-scoped** memo, not just any memo.
+
+3. **Trino is internally inconsistent:** it bakes a full `columnMetadataMap` into the fat handle at `getTableHandle` (`:178`) yet `getTableSchema` ignores it and re-derives column metadata through a fresh transaction (`:206-209`) — a redundant re-fetch even when the handle already carries the answer.
+
+4. **Hudi is the weakest:** even within one handle it rebuilds the `HoodieTableMetaClient` and force-reloads the timeline on every schema read (`:800-806`) — a Problem-Class-A loop-amplified heavy op with no hoist, the closest analog to the pre-cutover iceberg regressions this framework was built to fix.
\ No newline at end of file
diff --git a/plan-doc/connector-cache-unification/appendix-D-critic-punchlist.md b/plan-doc/connector-cache-unification/appendix-D-critic-punchlist.md
new file mode 100644
index 00000000000000..30fd0c1f0078a4
--- /dev/null
+++ b/plan-doc/connector-cache-unification/appendix-D-critic-punchlist.md
@@ -0,0 +1,20 @@
+## Overall: no major issues. Report is well-aligned with the evidence and has correctly folded in all three ADJUSTED corrections. A few minor edits below.
+
+### Punch-list
+
+**[low-med] §5.2 -> internal contradiction:** "全部 8 个写路径 getMetadata 接缝都经同一 funnel,**读写共享同一 memoized ConnectorMetadata**" is stated universally, but §3/HP-2 says jdbc's `buildInsertSql` news up a non-funneled `JdbcConnectorMetadata` — so for jdbc, read and write do NOT share one memoized metadata. -> Scope the sentence to the write-*transaction* connectors (hive/maxcompute/iceberg); add a clause that jdbc `buildInsertSql` is a connector-internal, non-funneled exception (P2, HP-2), correctness-neutral because jdbc write is BE auto-commit / `NoOpConnectorTransaction`.
+
+**[low] §1 -> phrasing vs audit severities:** "唯一接近 P0/P1 的真缺口是 hudi" reads as if mc/es aren't P1, but audits mark MC-1 and ES-F1/F2 as **P1**. -> Soften to "唯一 loop-amplified(iceberg-式)P1 缺口是 hudi;maxcompute/es 为 constant-factor P1",消除与 §5.1/§9 的口径分歧.
+
+**[low] §2 -> unverifiable attribution:** "fe-core 中经 funnel 的 call sites 约 58–59 处(INPUT A/C 计 58,INPUT B 计 59)" — INPUT C lists the seams but states no aggregate of 58. -> Drop the per-input attribution: "约 58–59(计法差异,不影响结论)".
+
+**[low] §2 -> consumer count only partly grounded:** "ConnectorPartitionViewCache 被 hive/iceberg/paimon 用 … 已有 3 个消费者". INPUT D confirms only **hive + paimon**; iceberg's use of the *generic* class is from INPUT A (not in the per-connector JSON). The javadoc-stale ("no consumers yet") claim still holds (≥2 confirmed). -> Attribute iceberg to INPUT A, or state "≥2 confirmed consumers (hive, paimon)".
+
+**[low] §9 cleanup list -> incomplete cite:** hive stale-doc list names `HiveConnector.wrapWithCache` but omits its line (hive audit pins `wrapWithCache:667-668`). -> Add `:667-668`.
+
+### Checks that PASSED (state for confidence)
+- **"已通用" vs INPUT C:** matches — INPUT C CONFIRMS the routing half is universal; the report correctly scopes true one-load-per-statement to iceberg only (§3 table, §4(2)) and never claims connectors memoize on the metadata instance. **No overclaim.**
+- **Migration status:** none mis-stated; hudi correctly "sibling / not in SPI_READY_TYPES / live", others correctly live.
+- **No recommendation built on a knocked-down finding:** the three ADJUSTED corrections (ES-F2 is not a zero-new-cache fix; HD-P03 "3x" is an upper bound; hudi authz-safety rests on empty `getCapabilities`, not the iceberg-specific guard) are all correctly incorporated.
+- **Roadmap completeness / no over-engineering:** every real P1 (hudi, maxcompute MC-1, es ES-F1/F2) is in the roadmap; jdbc/es/trino/paimon L1 caching correctly contraindicated, not assigned.
+- **authz/session=user (security):** iceberg correctly the sole `SUPPORTS_USER_SESSION`; all 7 others correctly single-identity — **no false negative** (incl. paimon REST vended-token correctly noted as table-level, not user-keyed; gate hard-wired to `IcebergConnector.java` correctly flagged as not protecting a future 2nd session=user connector).
\ No newline at end of file
diff --git a/plan-doc/connector-cache-unification/connectors/es.md b/plan-doc/connector-cache-unification/connectors/es.md
new file mode 100644
index 00000000000000..9e4f4e5d12996a
--- /dev/null
+++ b/plan-doc/connector-cache-unification/connectors/es.md
@@ -0,0 +1,103 @@
+## 连接器附录:es (Elasticsearch, fe-connector-es)
+
+### 结论摘要
+
+`es` 属于**读路径为主、无写入、无分区、单一凭证**的轻量连接器。它已经在 `SPI_READY_TYPES` 中,`EsConnectorMetadata` 已被 Layer-2 per-statement funnel 正确路由,但**连接器自身没有任何扫描元数据缓存**。iceberg 那套重型框架(Table/Partition/Format/Comment/Manifest cache、authz session=user 隔离、write-txn co-holder、per-file memo)**几乎全部不适用**。唯一真正值得做的是一个 **per-scan 的 `EsMetadataState` hoist**(把 shard routing + node 拓扑 + field-context 在一次扫描内解析一次,而不是两次),外加让扫描路径复用已被 `ExternalSchemaCache` 缓存的 mapping。都是 P1/P2 级别的**常数倍(2–4×)**冗余,而非 iceberg 那种 per-file 循环放大的 P0。
+
+### 迁移状态
+
+- `SPI_READY_TYPES` 含 `"es"`:`CatalogFactory.java:56-57`,createCatalog 走 SPI 路径(`:110` → `ConnectorFactory.createConnector` → `PluginDrivenExternalCatalog`)。
+- `EsConnectorProvider.getType()=="es"`(`EsConnectorProvider.java:34`),经 `META-INF/services` 发现。
+- **liveness = live**:legacy fe-core `EsExternalCatalog/EsExternalDatabase/EsExternalTable/EsScanNode` 已在 commit `4f455da5950` 删除,`BUILD_CONNECTOR_ES` 默认由 0 翻为 1;fe-core 仅残留与之无关的内表 `EsTable.java`/`EsResource.java` 与 `EsQuery` 函数。连接器是唯一活路径。
+
+### 现有缓存(caches table)
+
+| 缓存 | scope | key | TTL / 失效 | 位置 |
+|---|---|---|---|---|
+| `EsConnector.restClient`(REST 连接对象,DCL 懒建) | metastore-client | 无(每 catalog 一个) | catalog 生命周期,不失效 | `EsConnector.java:43,82-107` |
+| `EsConnectorRestClient.PLAIN_CLIENT / sslClient`(共享 OkHttp) | metastore-client | 无(JVM 全局) | 进程生命周期 | `EsConnectorRestClient.java:61-63,310-319` |
+| Layer-2 funnel:每 statement 记忆一个 `EsConnectorMetadata` | per-statement | `"metadata:"+catalogId`,`getUser` 身份钉死 | 一条语句;NONE 不记忆 | `PluginDrivenMetadata.java:64,70` |
+| `ResolvedScanProvider`:每 scan node 一个 `EsScanPlanProvider` | per-scan | `currentHandle` identity | scan node 生命周期 | `PluginDrivenScanNode.java:254-259` |
+| fe-core `ExternalSchemaCache` 包住 `initSchema()→getTableSchema()→getMapping()` | cross-query | `SchemaCacheKey`(index) | schema cache 过期 / REFRESH | `PluginDrivenExternalTable.java:430-470`;`ExternalTable.java:385-445` |
+| fe-core `RowCountCache` 包住 `fetchRowCount()`(ES 无 stats → UNKNOWN,无 ES 侧重 IO) | cross-query | table id | row-count cache 过期 | `PluginDrivenExternalTable.java:907,1121-1133` |
+| **连接器专用扫描元数据缓存(mapping/shard/node)** | **none** | **不存在** | — | `EsScanPlanProvider.java:99,169,273-294`(每次重取) |
+
+### Funnel 参与度
+
+`EsConnectorMetadata` 只经 `PluginDrivenMetadata.get(session, connector)` 获取(`PluginDrivenExternalTable.java:449,…,1277`;`PluginDrivenScanNode.java:1918-1920`),**确实被 funnel 路由**(每语句一个实例,arch gate 满足)。但该实例**不记忆任何东西**:
+
+- `getTableSchema`(`EsConnectorMetadata.java:81-94`)每次远程 `restClient.getMapping()`;`getColumnHandles`(`:97-106`)再调 `getTableSchema` → 第二次远程 mapping。
+- 更重的扫描态**根本不在 funneled metadata 上**:它在 `EsScanPlanProvider` 这个**独立对象**里(`EsConnector.getScanPlanProvider()` 每次 new,`EsConnector.java:56-58`;仅被 `ResolvedScanProvider` 每 scan node 记忆一次)。`planScan`(`:99`)与 `buildScanNodeProperties`(`:169`)各自独立 `fetchMetadataState`(`:273-294`)→ `getMapping + searchShards + getHttpNodes`。
+
+因此 funnel 的"每语句一份 metadata"**并未**折叠 ES 的重复远程加载。`metadataMemoizesLoadedTable = no`。
+
+### 热点重复加载审计(hot-path findings table)
+
+| id | area | 问题 | 倍数 | cost | 严重度 | 已记忆? | 位置 |
+|---|---|---|---|---|---|---|---|
+| ES-F1 | split-enum | `fetchMetadataState` 每查询 2 次(`planScan` `:99` + `buildScanNodeProperties` `:169`),每次 `searchShards` + `getHttpNodes`(`NODES_DISCOVERY_DEFAULT=true`)+ `getMapping`;同一 shard 路由/节点拓扑在一次 planning pass 内取两遍。provider 已是每 scan node 单实例,加个 `(index,columnNames)` 字段 memo 即 2→1,零陈旧风险 | 2×/查询 | remote-io | **P1** | 否 | `EsScanPlanProvider.java:99,169,273-294`;`EsMetadataFetcher.java:51-89` |
+| ES-F2 | predicate | `fetchMapping`(`EsMetadataFetcher.java:57-58` → `getMapping` `RestClient:161`)取的 index mapping 已在 schema 解析时被 `ExternalSchemaCache` 缓存;扫描路径不复用而重新远程取 | ~2×/查询 | remote-io | **P1** | 否 | `EsMetadataFetcher.java:57-58`;`EsConnectorRestClient.java:161-168` |
+| ES-F3 | schema | `buildColumnHandles→getColumnHandles→getTableSchema→getMapping`(裸远程,绕过 schema cache);`buildColumnHandles` 每查询≥2 次(`PluginDrivenScanNode.java:1263` getSplits、`:1841` getOrLoadPropertiesResult)。funneled metadata 是每语句单实例,在其上记忆 schema 即可折叠到 1× | 2×/查询 | remote-io | P2 | 否 | `EsConnectorMetadata.java:81-106`;`PluginDrivenScanNode.java:1263,1841,1917-1920` |
+| ES-F4 | file-list | `getTableHandle→existIndex` 每次解析 handle 发一次 `index/_mapping` GET(`EsConnectorMetadata.java:74`;`RestClient:104-114`);轻量、row-count 侧有 RowCountCache;仅列完整性,非热循环 | 1×/handle | remote-io | P2 | 否 | `EsConnectorMetadata.java:71-78`;`EsConnectorRestClient.java:104-114` |
+
+合计:一条普通 `SELECT ... WHERE ...`(schema cache 命中的暖态)对同一 ES 集群大约发出 **~4× mapping + 2× search_shards + 2× _nodes/http** 远程往返(每次 OkHttp 10s read timeout),其中 mapping 完全冗余(已被 schema cache 缓存),shard/node 在语句内 2× 冗余。
+
+**没有 iceberg 式的 per-file/per-split 循环放大**:`planScan` 从内存中的 shard 路由 map 简单循环生成 scan range(`EsScanPlanProvider.java:113-138`),循环内无远程调用。所以是 variant B/D(单链重复 k 次 / 循环不变量未提升),不是 variant A。
+
+### Authz / 一致性
+
+- `perUserCredential = false`、`sessionUserRelevant = false`。ES 用**单一 catalog 级 user/password**构建共享 `EsConnectorRestClient`(`EsConnector.java:95-100`;`EsConnectorRestClient.java:76-78` 一个 Basic auth header),无 `iceberg.rest.session=user`、无 kerberos doAs、无 REST/OIDC 每身份委派——所有 Doris 用户以同一身份访问 ES。因此 **name-only 缓存不会造成 list≠load 元数据泄露**,`tools/check-authz-cache-sharding.sh` 的 session=user 隔离对本连接器 **N/A**。
+- 唯一一致性关切是**数据新鲜度**:ES shard 会 rebalance(ES 自身 refresh 模型),shard/node 拓扑必须每语句解析、**不得跨查询缓存**——这与被删的 legacy `EsScanNode`/`EsMetaStateTracker` 每次扫描重解析 shard 一致。
+- **无写路径**(`EsConnectorMetadata` 只覆写 list/exists/getTableHandle/getTableSchema/getColumnHandles/buildTableDescriptor;无 beginWrite/commit/insert/sink),故无"读写共享一份 metadata/一个 txn"的需求;ES 只读且非分区(未覆写 listPartitions),无 snapshot/version 钉需求。
+
+### 框架各件需求
+
+| piece | 需要 | 理由 |
+|---|---|---|
+| per-scan-hoist | **yes** | 唯一真正收益:把 `EsMetadataState` 每扫描解析一次(ES-F1)。provider 已单实例,字段 memo 即可,零陈旧风险。 |
+| per-statement-funnel-memoization | partial | funnel 已路由且 arch gate 满足,但连接器未利用:schema/getColumnHandles 仍每次远程取(ES-F3),重扫描态在 funnel 覆盖不到的独立 provider 上。 |
+| cross-query-table-cache | partial | mapping 已被 `ExternalSchemaCache` 跨查询缓存,扫描路径复用即可(ES-F2);无需新建连接器 Table cache;shard/node **禁止**跨查询缓存。 |
+| shared-fe-connector-cache-adoption | no | 连接器未用 fe-connector-cache,且推荐修复是 per-statement/per-scan memo 而非托管跨查询缓存,无需引入该 toolkit。 |
+| partition-cache | no | ES 单索引非分区,未覆写 listPartitions;无 PARTITIONS/SHOW PARTITIONS 重路径。 |
+| format-cache | no | 格式固定常量 `es_http`(`EsScanPlanProvider.java:174`),无 iceberg PERF-03 的整表 planFiles 回退。 |
+| comment-cache | no | 未覆写 getComment,无 N-per-query 远程 comment 加载。 |
+| manifest-or-file-list-cache | no | 无 manifest/文件清单;shard 取数是结构类比但新鲜度敏感,归入 per-scan-hoist。 |
+| per-file-split-memo | no | scan range 从内存 shard map 循环生成,循环内无远程调用。 |
+| authz-session-user-isolation | no | 单凭证,无 session=user/OIDC/kerberos。 |
+| write-txn-coholder | no | 只读连接器。 |
+
+### 优先级建议
+
+1. **(P1)ES-F1 per-scan hoist**:在 `EsScanPlanProvider` 上按 `(indexName, columnNames)` 记忆一次 `EsMetadataState`,让 `planScan` 与 `getScanNodePropertiesResult` 共享同一次 shard/node 解析。这是最干净、零风险、收益最大的改动(shard 取数 2→1、node 取数 2→1)。
+2. **(P1)ES-F2 复用已缓存 mapping**:扫描路径的 field-context 从已被 `ExternalSchemaCache` 缓存的 schema 派生,消除扫描侧的冗余 mapping 远程取,避免重复 `getMapping`。
+3. **(P2)ES-F3 metadata schema memo**:在每语句单实例的 `EsConnectorMetadata` 上记忆已解析 schema,使 `getColumnHandles` 折叠到 1×。
+4. **不做**:跨查询 shard 缓存(违背 ES rebalance/refresh 模型)、authz 隔离、write-txn、partition/format/manifest/comment 缓存。
+
+---
+
+## 复核结论 (adversarial verify)
+
+**Verdict: ADJUSTED** · 确认发现 IDs: ES-F1, ES-F2, ES-F3, ES-F4
+
+| 原始声明 | 问题 | 更正 | 证据 |
+|---|---|---|---|
+| frameworkPiecesNeeded.cross-query-table-cache + ES-F2 fix rationale: 'Serving the scan-path field-context from the already-cached schema removes the redundant cross-query mapping fetch WITHOUT a new cache' / 'reuse the cached schema'. | The fe-core ExternalSchemaCache stores only the parsed ConnectorTableSchema (column list). EsConnectorMetadata.getTableSchema builds `new ConnectorTableSchema(indexName, columns, "ELASTICSEARCH", Collections.emptyMap())` (EsConnectorMetadata.java:90-93) — an EMPTY properties map; the raw mapping JSON is discarded. But EsMetadataFetcher.fetchMapping needs the raw mapping (keyword-sniff / doc_value / date-compat) via EsMappingUtils.resolveFieldContext(columnNames, sourceIndex, indexMapping, mappingType) (EsMetadataFetcher.java:57-63). So the scan-path field context CANNOT be derived from the currently cached schema value. | The redundant remote mapping fetch (ES-F2) is REAL and the multiplier holds, but the proposed fix is understated: eliminating it requires either enriching ConnectorTableSchema to carry the raw mapping/field-context, or adding a per-statement/cross-query mapping (field-context) cache — i.e. it is NOT a zero-new-cache 'just reuse the schema' change. Files: fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java:90-93; EsMetadataFetcher.java:57-63; EsMappingUtils.resolveFieldContext. | fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java |
+| currentCaches RowCountCache row: 'ES has no stats -> returns UNKNOWN, no ES-side remote heavy op'. | fetchRowCount (PluginDrivenExternalTable.java:1121-1133) first calls resolveConnectorTableHandle -> EsConnectorMetadata.getTableHandle -> restClient.existIndex (a remote `index/_mapping` GET, EsConnectorRestClient.java:104-114) BEFORE it can return UNKNOWN. EsConnectorMetadata does not override getTableStatistics (confirmed by grep), so getTableStatistics itself makes no ES RPC, but the handle-resolution step does perform one lightweight remote GET. | The phrase is defensible for 'heavy' op (there is none), but 'no ES-side remote op' would be wrong: the row-count seam still issues one lightweight existIndex `_mapping` GET during handle resolution (this is the same op as ES-F4). It sits behind RowCountCache so it is not per-query hot; precision note only. File: fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java:1121-1133. | fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java |
+
+> 复核备注:CORE STANDS — every material claim verified against HEAD (aaab68ef474). Corrections are limited to fix-feasibility/wording nuances; no finding, multiplier, severity, or memoized-status is refuted.
+
+MIGRATION STATUS — CONFIRMED. SPI_READY_TYPES includes "es" at CatalogFactory.java:56-57 (ImmutableSet.of("jdbc","es","trino-connector","max_compute","paimon","iceberg","hms")); SPI routing at ~line 110. EsConnectorProvider.getType() returns "es" (line 34). Legacy fe-core EsExternalCatalog/EsExternalDatabase/EsExternalTable/EsScanNode are ABSENT (find returned nothing); internal-catalog EsTable.java/EsResource.java remain. Connector is sole live ES path. (Did not locate BUILD_CONNECTOR_ES in pom.xml/build.sh — a peripheral historical detail, not required for any technical claim.)
+
+CACHES — CONFIRMED. EsConnector.restClient DCL memo (EsConnector.java:43,82-107); static PLAIN_CLIENT/sslClient with 10s readTimeout (EsConnectorRestClient.java:61-63,310-319); single Basic authHeader built once (76-78). Layer-2 funnel memo keyed "metadata:"+catalogId + identity pin "metadata-identity:"+catalogId via getUser (PluginDrivenMetadata.java:64,70). ResolvedScanProvider memo keyed on currentHandle identity (PluginDrivenScanNode.java:254-256, class at 267). RowCountCache at PluginDrivenExternalTable.java:907. No dedicated connector scan-metadata cache — confirmed absent (EsScanPlanProvider re-fetches).
+
+FUNNEL PARTICIPATION — CONFIRMED (funneled=true, metadataMemoizesLoadedTable=no). EsConnectorMetadata holds only restClient+properties; getTableSchema (81-94) calls restClient.getMapping every call; getColumnHandles (97-106) re-invokes getTableSchema -> 2nd remote GET; no memo fields. EsScanPlanProvider is a separate object (EsConnector.java:56-58 returns new provider each call), memoized only per scan node.
+
+HOT-PATH FINDINGS — all four CONFIRMED:
+- ES-F1 (P1, 2x, not memoized): fetchMetadataState called from planScan (EsScanPlanProvider.java:99, reached via getSplits->planScan at PluginDrivenScanNode.java:1303) AND from buildScanNodeProperties (line 169, reached via getScanNodePropertiesResult at PluginDrivenScanNode.java:1863). getOrLoadPropertiesResult IS cached (cachedPropertiesResult, line 1838/1870), so the second path fires exactly once — 2x total, constant, verified. Each fetch = fetchMapping+searchShards+getHttpNodes (EsMetadataFetcher.java:51-89); NODES_DISCOVERY_DEFAULT="true" (EsConnectorProperties.java:38) so getHttpNodes really runs. planScan loop (113-138) is over an in-memory routing map with no remote call — correctly characterized as variant B/D, not loop-amplified.
+- ES-F2 (P1, ~2x): fetchMapping->getMapping (EsMetadataFetcher.java:57-58; RestClient:161-168) duplicates the schema-resolution mapping fetch. Real; see correction on fix scope.
+- ES-F3 (P2, 2x): buildColumnHandles (PluginDrivenScanNode.java:1917-1920) -> getColumnHandles -> getTableSchema -> raw getMapping, bypassing ExternalSchemaCache; buildColumnHandles runs at 1263 (getSplits) and 1841 (getOrLoadPropertiesResult) — 2x confirmed.
+- ES-F4 (P2, 1x/handle): getTableHandle->existIndex `_mapping` GET (EsConnectorMetadata.java:74; RestClient:104-114). Confirmed low-cost.
+
+Per-SELECT remote tally (~4x getMapping + 2x searchShards + 2x _nodes/http) reproduces from the code. No missed P0: no heavy op sits in a per-split/per-file loop, so the audit does not UNDERSTATE anything — worst case is constant-factor.
+
+AUTHZ — CONFIRMED. Single catalog-level Basic credential (EsConnector.java:95-100; RestClient:76-78); no session=user/OIDC/kerberos. EsConnectorMetadata overrides no write/partition/comment/stats methods (grep for getTableStatistics/listPartitions/getTableComment/beginWrite/applyFilter returned nothing) — read-only, non-partitioned confirmed. authz-session-user-isolation N/A is correct. Shard/node freshness rationale (do NOT cross-query cache) is sound.
diff --git a/plan-doc/connector-cache-unification/connectors/hive.md b/plan-doc/connector-cache-unification/connectors/hive.md
new file mode 100644
index 00000000000000..b2f680f9763156
--- /dev/null
+++ b/plan-doc/connector-cache-unification/connectors/hive.md
@@ -0,0 +1,97 @@
+## 连接器附录 — hive (catalog type `hms`)
+
+### 结论速览
+
+hive 连接器在 HEAD 已经是**框架对齐 + 缓存充分**的状态,**不需要**再套用 iceberg 的 hot-path 缓存改造。它已经具备参考 commit `0b4f72582e7` 三层框架里对 hive 有意义的全部部件:Layer-1 的连接器侧 cross-query 缓存以 hive 自己的形态存在(`CachingHmsClient` 四缓存 + `HiveFileListingCache` + `ConnectorPartitionViewCache`),Layer-2 的 per-statement funnel 完整接入(主连接器走 `PluginDrivenMetadata.get`,sibling 走 `memoizedSiblingMetadata`,写路径有 per-statement 的 `HiveConnectorTransaction`),Layer-3 的 authz 隔离对 hive **不适用**(无 session=user)。所有 planning 入口的重 IO 都命中缓存,**没有** iceberg 那种「同一 planning pass 里 loadTable 3-7 次」的回归。
+
+> ⚠ 文档漂移:`CachingHmsClient.java:85-88`、`HiveFileListingCache.java:72-74`、`HiveConnector.wrapWithCache` 的 javadoc(:667-668)仍写着 "Dormant / `hms` is not in `SPI_READY_TYPES`",这在 HEAD 是**错的**——commit `6e521aa64b2`(#65473) 同时把 `hms` 放进 `SPI_READY_TYPES`(CatalogFactory.java:57) 并在 `createClient()` 里接线了 `wrapWithCache`(:645-646),却没同步更新这几处类注释。纯文档问题,不影响运行。
+
+### 迁移状态
+
+- `hms` ∈ `SPI_READY_TYPES`(`CatalogFactory.java:57`)→ **LIVE**。fe-core 用 `PluginDrivenExternalCatalog` 包 `HiveConnector`(`CatalogFactory.java:110-118`)。
+- 同时是**异构网关(gateway)**:iceberg-on-HMS / hudi-on-HMS 作为 embedded SIBLING 连接器,在各自 child-first classloader 里按需构建(`HiveConnector.java:530-592`),其 `ConnectorMetadata` 按 owner label 做 per-statement 记忆化(`HiveConnectorMetadata.memoizedSiblingMetadata`,:302-305)。hudi 不是独立 SPI 类型(`CatalogFactory.java:51-55`)。
+
+### 缓存清单
+
+| 缓存 | scope | key | TTL/容量 | 失效 | 位置 |
+|---|---|---|---|---|---|
+| `CachingHmsClient.tableCache` | metastore-client | (db,table)→HmsTableInfo | 24h / 1万;兼容 `schema.cache.ttl-second` | REFRESH flush/flushDb/flushAll | `CachingHmsClient.java:116,172` |
+| `CachingHmsClient.partitionNamesCache` | metastore-client | (db,table,maxParts) | 24h / 1万;兼容 `partition.cache.ttl-second` | 同上 + `invalidatePartitions` | `CachingHmsClient.java:117,187` |
+| `CachingHmsClient.partitionsCache` | metastore-client | (db,table,partitionValues),一分区一条 | 24h / 10万;put 有 generation guard 防 REFRESH race | 同上(按分区) | `CachingHmsClient.java:118,202-243` |
+| `CachingHmsClient.columnStatsCache` | metastore-client | (db,table,columns) | 24h / 1万 | 同上 | `CachingHmsClient.java:119,274` |
+| `HiveFileListingCache` | cross-query | (db,table,location,partitionValues)→List\ | 24h / 1万;兼容 `file.meta.cache.ttl-second` | table/db/partitions/all | `HiveFileListingCache.java:114,160-176` |
+| `HiveConnector.partitionViewCache`(`ConnectorPartitionViewCache`,#65829 的 PERF-06 cache A) | cross-query | (db,table,-1,-1)(hive 无 snapshot/无 schema version,一表一条)→List\ | 24h / 1000 | table/db/all | `HiveConnector.java:112,136`;消费 `HiveConnectorMetadata.java:1160-1172` |
+| `memoizedSiblingMetadata`(iceberg/hudi sibling 元数据) | per-statement | `metadata:{catalogId}:{ownerLabel}` | statement 生命周期;NONE scope 不记忆 | 随 scope 关闭 | `HiveConnectorMetadata.java:302-305` |
+| `HiveConnectorTransaction` begin-time 表快照 | per-statement | 唯一写目标表(`hmsTableInfo`/`tableActions`) | 写事务生命周期 | 事务结束 | `HiveConnectorTransaction.java:209,546-558` |
+| `ThriftHmsClient` 连接池(非元数据缓存,仅传输) | metastore-client | pool size = `hms.client.pool.size` | 连接生命周期 | — | `HiveConnector.java:613-617` |
+
+关键点:`HiveConnector.createClient()` 用 `wrapWithCache` 把裸 `ThriftHmsClient` 包成 `CachingHmsClient`(:645-646,670-672),所以 `HiveConnectorMetadata` 里**所有** `hmsClient.getTable/listPartitionNames/getPartitions/getTableColumnStatistics` 调用都透明走 cross-query 缓存——这正是 hive 不需要 iceberg PERF-07(per-statement 表加载)的原因:iceberg 在 cutover 后**没有** cross-query 表缓存,hive 有。
+
+### funnel 参与
+
+- **主路径**:fe-core 所有 seam 经 `PluginDrivenMetadata.get(session, connector)`(funnel 唯一合法直呼点,`PluginDrivenMetadata.java:52-72`;调用点 `PluginDrivenExternalTable.java:133,159,189,449,...`)→ `HiveConnector.getMetadata`→`newMetadata(getOrCreateClient())`(`HiveConnector.java:140-142`)。**一 statement 一 ConnectorMetadata** 不变式成立(`tools/check-fecore-metadata-funnel.sh` 全构建守门)。
+- **读侧是否 per-metadata 记忆化已加载表**:**否**——`HiveConnectorMetadata` 无 `resolvedTable` 字段(字段仅 hmsClient/缓存/sibling seam,:203-241),`getTableHandle`(:399)/`getTableSchema`(:493)/`getColumnHandles`(:604)/`getColumnStatistics`(:854)/`applyFilter`(:1093) 各自 fresh 调 getTable,但**由 CachingHmsClient cross-query 缓存兜底**,同 statement 内重复加载 = 1 RPC + N 命中。**写侧**额外在写事务内记忆 begin-time 表快照(`HiveConnectorTransaction.java:549-551`)。
+- **sibling 路径**:iceberg/hudi 元数据按 (catalogId, ownerLabel) per-statement 记忆化,保证一 statement 内同 owner 复用一个 metadata(:302-350)。
+
+### hot-path 逐项审计
+
+| ID | 入口 | 重 op & 倍率 | 是否已记忆 | 严重度 | 位置 |
+|---|---|---|---|---|---|
+| HMS-H1 | table/schema 加载 | getTable 每 pass 3-4 次 → 1 RPC + N 命中(tableCache) | ✅ | none | `HiveConnectorMetadata.java:399,493,604,854` |
+| HMS-H2 | 分区枚举(pruning/scan/stats/MTMV) | listPartitionNames+getPartitions 多次 → 全缓存(names/partitions/view 三层) | ✅ | none | `:1093-1106,1019-1025,1160-1172`;`HiveScanPlanProvider.java:492-499` |
+| HMS-H3 | 分区目录 listStatus(scan 最重 IO,O(分区)) | 每分区一次 → `HiveFileListingCache` cross-query,scan 与 size-estimate 互暖 | ✅ | none | `HiveScanPlanProvider.java:537`;`HiveConnectorMetadata.java:945,1062` |
+| HMS-H4 | ACID split 枚举 | `HiveAcidUtil.getAcidState` 每分区每 scan,**未缓存** | ❌ | none(按设计) | `HiveScanPlanProvider.java:314` |
+| HMS-H5 | 写规划 | loadTable + beginWrite 双载 + buildExistingPartitions → 缓存 + 事务内快照复用 | ✅ | none | `HiveWritePlanProvider.java:103,105,255-258`;`HiveConnectorTransaction.java:549-551` |
+| HMS-H6 | 读侧无 per-statement 表 memo | 仅靠 CachingHmsClient TTL;同 statement 两次 getTable 之间若发生淘汰/过期 → 第二次 RPC | ❌ | **P2** | `HiveConnectorMetadata.java:203-241` |
+
+- **HMS-H4 是「有意不缓存」**:ACID 目录状态依赖 valid write-id 快照(每事务变),cross-query 缓存会读到过期数据;`getFileListingCache` 注释明确只服务非 ACID 路径(`HiveScanPlanProvider.java:103-104`),与 legacy 一致,**不是缺陷**。
+- **SHOW PARTITIONS 走 `listPartitionNamesFresh`(绕缓存)**(`CachingHmsClient.java:192-199`;`HiveConnectorMetadata.java:1130-1136`)也是**有意 fresh**(`use_meta_cache` 契约,legacy parity),非缺陷。
+
+### authz / 一致性
+
+hive **不 vend 任何 per-user 凭证**、**无 session=user 类比**。metastore RPC 使用**单一 catalog 级身份**:要么 plugin 侧 Kerberos keytab 固定 principal(`HiveConnector.buildPluginAuthenticator`,:710-740),要么 `context.executeAuthenticated`(catalog 级),**从不**是查询用户的委派凭证,也没有 REST OIDC / per-identity metastore。因此 name-only 的缓存 key(db,table[,parts/cols/location])**不会**像 `iceberg.rest.session=user` 那样泄漏跨用户元数据——所有用户看到同一 metastore 身份的视图,用户级访问由 Doris fe-core RBAC 独立管控。这正是 authz 守门脚本 `tools/check-authz-cache-sharding.sh` **只针对 `IcebergConnector.java`** 的原因,hive 无条件构建缓存(`HiveConnector.java:108-112,133-136`)。另有一处 fail-loud 不变式(`HiveConnector.java:548-555`):若 iceberg-on-HMS sibling 竟声明 `SUPPORTS_USER_SESSION` 就直接抛错——恰因 hive 前门不是 session=user、fe-core 的 per-user schema/name 缓存 bypass 是按前门连接器 keyed 的。写读一致性:读写共享同一 cross-query `CachingHmsClient`(不可变 `HmsTableInfo`/`HmsPartitionInfo`),写事务钉住 begin-time 表快照,ACID 读每 scan 钉 write-id 快照(`getValidWriteIds`)。陈旧性由 coarse REFRESH(flush/flushDb/flushAll,`HiveConnector.java:357-419`)+ TTL 收敛。**Layer-3 对 hive 不适用。**
+
+### 框架部件需求判定
+
+| 部件 | 判定 | 依据 |
+|---|---|---|
+| cross-query-table-cache | already-has | `CachingHmsClient.tableCache`(iceberg `IcebergTableCache` 的 metastore-client 类比) |
+| partition-cache | already-has | partitionNamesCache + partitionsCache + `ConnectorPartitionViewCache` 三层 |
+| manifest-or-file-list-cache | already-has | `HiveFileListingCache`(manifest cache 的 hive 类比) |
+| shared-fe-connector-cache-adoption | already-has | 全程用 `MetaCacheEntry`/`CacheSpec`/`ConnectorPartitionViewCache`/`PartitionViewCacheKey` |
+| write-txn-coholder | already-has | `HiveConnectorTransaction` per-statement 写事务,co-hold begin-time 表 |
+| per-scan-hoist | already-has | format/split-size/splittable/storage-props 均在分区循环前算一次 |
+| per-statement-funnel-memoization | no | cross-query 缓存已折叠重复加载;HMS getTable 远轻于 iceberg loadTable,不值当 |
+| format-cache | no | 从 handle inputFormat/serde 廉价推断,无 iceberg 那种整表 planFiles fallback |
+| comment-cache | no | comment 来自已缓存的 getTable params/ConnectorColumn,无独立 N-per-query 远程 getComment |
+| per-file-split-memo | no | splitFile 按引用共享分区 value map,无 per-file 派生可 memo |
+| authz-session-user-isolation | no | 无 per-user 凭证 / 无 session=user;RBAC 独立管控 |
+
+### 优先建议(均为 LOW)
+
+1. **文档修正**:把 `CachingHmsClient.java:85-88`、`HiveFileListingCache.java:72-74`、`HiveConnector.wrapWithCache`(:667-668) 里过期的 "Dormant / hms not in SPI_READY_TYPES" 注释改为「live」——纯文档。
+2. **可选 P2**:给读侧 `HiveConnectorMetadata` 加一个 per-statement `resolvedTable` memo(iceberg PERF-07 类比),关掉 HMS-H6 那个极罕见的「statement 中途缓存淘汰导致二次 RPC」race。成本收益看**不建议**做(HMS getTable 很轻)。
+3. **保持** ACID `getAcidState` per-scan 不缓存(HMS-H4)——快照/write-id 依赖,legacy parity 正确。
+
+---
+
+## 复核结论 (adversarial verify)
+
+**Verdict: CONFIRMED** · 确认发现 IDs: HMS-H1, HMS-H2, HMS-H3, HMS-H4, HMS-H5, HMS-H6
+
+| 原始声明 | 问题 | 更正 | 证据 |
+|---|---|---|---|
+| HiveConnectorTransaction begin-time table snapshot ... hmsTableInfo captured in beginWrite (HiveConnectorTransaction.java:209) | Off-by-2 line cite: the hmsTableInfo field is declared at :123 and assigned inside beginWrite at :211, not :209. Immaterial — :209 falls within the beginWrite method and the reuse-in-getTable range :546-558 (specifically :549-551) is exact. | Field decl HiveConnectorTransaction.java:123 (private volatile HmsTableInfo hmsTableInfo); assignment this.hmsTableInfo = table at :211; reuse at :551. No substantive error. | |
+| detailedMarkdown enumerates only 3 stale 'Dormant/hms not in SPI_READY_TYPES' javadocs (CachingHmsClient:85-88, HiveFileListingCache:72-74, HiveConnector.wrapWithCache:667-668) | Non-exhaustive, not wrong: the icebergSibling/hudiSibling field comments at HiveConnector.java:118 and :126-127 also carry stale 'Dormant until hms enters SPI_READY_TYPES' text at HEAD. | Two additional stale doc sites exist at HiveConnector.java:118,126-127; harmless, additive to the same doc-fix recommendation (LOW). | |
+
+> 复核备注:Every material claim holds against HEAD (aaab68ef474). MIGRATION: hms in SPI_READY_TYPES (CatalogFactory.java:56-57); hudi correctly not a standalone type; createClient wraps ThriftHmsClient in CachingHmsClient (HiveConnector.java:645-646, wrapWithCache:670-672). The three stale 'Dormant' javadocs are genuinely factually wrong at HEAD (hms is live) — doc-fix recommendation valid.
+
+CACHES (all 9 verified, no hallucinations, scope/key/TTL correct): tableCache field :116 / getTable :172-175 (db,table); partitionNamesCache :117 / :187-190 (db,table,maxParts); partitionsCache :118 / :202-243 per-partition entry keyed by values, generation-guarded put :235-238; columnStatsCache :119 / :274-279; DEFAULT_TTL 86400 (:109), caps 10000/10000/100000/10000 (:110-113) all match. HiveFileListingCache :114 / listDataFiles cache.get :174 keyed (db,table,location,partitionValues); TTL 86400 cap 10000 (:92-93); legacy file.meta.cache.ttl-second remap :87,141-143. partitionViewCache field HiveConnector.java:112 constructed :136 (ConnectorPartitionViewCache), consumed HiveConnectorMetadata listPartitions :1170-1172 keyed (db,table,-1,-1). memoizedSiblingMetadata :302-305 keyed 'metadata:'+catalogId+':'+ownerLabel via statementScope.getOrCreateMetadata (per-statement, NONE=no memo). Shared toolkit classes (ConnectorPartitionViewCache/PartitionViewCacheKey/MetaCacheEntry/CacheSpec) all exist in fe-connector-cache and are imported (CachingHmsClient :20-21).
+
+FUNNEL: PluginDrivenMetadata.get (fe-core :53, memoizes on scope.getOrCreateMetadata('metadata:'+catalogId) :70); PluginDrivenExternalTable calls it 16×; getMetadata->newMetadata(getOrCreateClient()) HiveConnector :140-142,188-192. Read-side HiveConnectorMetadata fields :203-241 hold hmsClient/caches/sibling seams only — NO resolvedTable field, so getTable at :399/:493/:604 and col-stats :854 re-call fresh but collapse via CachingHmsClient; write-side reuses hmsTableInfo begin-time snapshot :551. 'metadataMemoizesLoadedTable: partial' is accurate.
+
+HOT-PATH FINDINGS all 6 confirmed at exact sites: H1 getTable :399/:493/:604 + getTableColumnStatistics :854, all via CachingHmsClient (severity none, memoized) — multiplier 3-4x not overstated. H2 applyFilter listPartitionNames :1093 + getPartitions :1105; resolvePartitionRefs :1019-1025; listPartitions partitionViewCache :1160-1172 — three-layer cache, none. H3 fileListingCache.listDataFiles HiveScanPlanProvider :537 (non-ACID) + sumCachedFileSizes HiveConnectorMetadata :1062-1063, HiveFileListingCache :174 — shared scan/estimate, none. H4 HiveAcidUtil.getAcidState HiveScanPlanProvider :314 inside per-partition loop (:307), genuinely uncached, snapshot/write-id dependent (by design, none). H5 loadTable HiveWritePlanProvider :103 (hmsClient.getTable :353) + beginWrite :105 double-load accepted + buildExistingPartitions listPartitionNames/getPartitions :255-258, collapsed by cache + txn snapshot :549-551. H6 no per-statement resolvedTable memo (fields :203-241) — residual mid-statement TTL/eviction re-RPC race, P2, correctly characterized as not warranted.
+
+AUTHZ: getCapabilities HiveConnector :301,341-344 returns SUPPORTS_VIEW/METADATA_PRELOAD/MVCC_SNAPSHOT — NO SUPPORTS_USER_SESSION; no per-user credential (auth via fixed keytab principal buildPluginAuthenticator :710-740 or context.executeAuthenticated :638). authz-cache-sharding gate targets ONLY IcebergConnector (TARGET_REL :57); fail-loud sibling guard :548-555; shouldBypassSchemaCache/SUPPORTS_USER_SESSION exist in fe-core PluginDrivenExternalCatalog :1179,1212. Layer-3 N/A for hive is correct.
+
+NO MISSED SEVERE FINDING: MTMV freshness (getTableFreshness :1296-1327, getPartitionFreshnessMillis :1338-1350) reads cached collectPartitionNames + hmsClient.getPartitions — no uncached heavy op. SHOW PARTITIONS intentionally fresh (listPartitionNamesFresh :193-199, HiveConnectorMetadata :1130-1136) is interactive DDL, not a hot path. splitFile :628 shares PartitionScanInfo :707 by reference (no per-file recompute). Overall verdict 'DO NOT apply new iceberg-style caches to hive' is well-supported: every planning entrypoint sits behind a live REFRESH/TTL-invalidated cache. Confidence: high.
diff --git a/plan-doc/connector-cache-unification/connectors/hudi.md b/plan-doc/connector-cache-unification/connectors/hudi.md
new file mode 100644
index 00000000000000..500108a54b5920
--- /dev/null
+++ b/plan-doc/connector-cache-unification/connectors/hudi.md
@@ -0,0 +1,94 @@
+## 连接器附录 — hudi (fe-connector-hudi)
+
+### 1. 迁移状态 (Migration status)
+
+- **`hudi` 不在 `SPI_READY_TYPES`(设计如此,正确)**。`CatalogFactory.java:56-57` 的白名单是 `{jdbc, es, trino-connector, max_compute, paimon, iceberg, hms}`;`:50-55` 的注释明确禁止加入 `"hudi"`。没有独立的 `HudiExternalCatalog`;`HudiConnectorProvider.java:45-47` 返回的是 sibling-only 类型串 `"hudi"`。
+- **hudi 通过 hms gateway 以 sibling 形式暴露,且已 LIVE**。`HiveConnector.newMetadata` 把 `this::getOrCreateHudiSibling` 接入 `HiveConnectorMetadata`(`HiveConnector.java:188-191`),`HiveConnectorMetadata.getTableHandle` 对检测为 HUDI 的表转发到 `hudiSiblingMetadata(session).getTableHandle(...)`(`HiveConnectorMetadata.java:415-417`)。`getOrCreateHudiSibling` 通过 `context.createSiblingConnector("hudi", ...)` 构建唯一 sibling(`HiveConnector.java:576-591`)。
+- **liveness = live(非 dormant)**:`"hms"` 已由 **#65473 / commit `6e521aa64b2`(2026-07-16)** 加入 `SPI_READY_TYPES`;且 fe-core 侧的 legacy hudi 路径(`HudiScanNode` / `HudiExternalMetaCache` / `HMSExternalTable` / `HudiDlaTable`)已全部删除,连接器路径是 hudi 的唯一路径。
+- **⚠ 需暴露的冲突(Rule 7)**:源码中仍散落大量 `"dormant until hms enters SPI_READY_TYPES"` / `"today getTableHandle is never called"` 注释(`HudiConnectorMetadata.java:391`、`HiveConnectorMetadata.java:410,1802,1942,1972,2007`)。这些注释写于 #65473 之前,已 **STALE**,与 HEAD 的 `SPI_READY_TYPES` 事实矛盾,不能据其判定 liveness,应清理。
+
+### 2. 现有缓存 (Current caches)
+
+| 缓存 | scope | key | TTL / 失效 | file:line | 说明 |
+|---|---|---|---|---|---|
+| per-statement `ConnectorMetadata`(funnel) | per-statement | `(catalogId, HUDI_LABEL)` on `ConnectorStatementScope` | 语句结束 | `HiveConnectorMetadata.java:302` | Layer-2 唯一 memo;只 memo **对象**,不 memo 已加载的 metaClient/schema |
+| HMS `ThriftHmsClient`(**RAW,无结果缓存**) | metastore-client | 单个连接池 client memo 在 `HudiConnector` 上 | 无(连接器生命周期) | `HudiConnector.java:186` | **未包 `CachingHmsClient`**:`getTable/tableExists/listTables/listPartitionNames` 每次都是新 Thrift RPC |
+| `pluginAuth`(`HadoopAuthenticator`) | cross-query | catalog 级单一 Kerberos 身份 | 连接器生命周期 | `HudiConnector.java:196` | 认证对象,非元数据缓存 |
+| Hudi `InternalSchemaCache`(库内部,非 Doris 缓存) | none | `(commitTime, metaClient)` in hudi-common | 库自管 | `HudiSchemaUtils.java:278` | 随每次新建 metaClient 基本重置 |
+
+**结论**:hudi **没有任何连接器侧 cross-query 元数据缓存**,**没有 per-statement 的 metaClient/schema memo**,**完全没用**共享工具箱(pom 无 `fe-connector-cache` 依赖,无 `CacheFactory/CacheSpec/MetaCacheEntry/ConnectorPartitionViewCache`),甚至没有像 `HiveConnector` 那样 `wrapWithCache` 包 `CachingHmsClient`。它是所有 SPI 连接器里缓存最薄的一个。
+
+### 3. Funnel 参与 (Funnel participation)
+
+- **已接入 Layer-2 funnel**:sibling 元数据经 `HiveConnectorMetadata.memoizedSiblingMetadata`(`:302-305`)→ `session.getStatementScope().getOrCreateMetadata("metadata:"+catalogId+":"+ownerLabel, () -> owner.getMetadata(session))`,`hudiSiblingMetadata`(`:332-334`)与 by-handle `siblingMetadata`(`:347-350`)在同一语句内共用同一实例(`HUDI_LABEL` keyed)。故一条语句内只构建 **一个** `HudiConnectorMetadata`。
+- **但该实例内部零 memo**:`HudiConnectorMetadata` 字段仅 `{hmsClient, properties, metaClientExecutor, storageHadoopConfig}`(`:153-174`),无任何 resolved metaClient/schema/timeline/partitions 缓存字段。每个 `getTableSchema` / `getColumnHandles` / `applyFilter` / `listPartitions*` / `beginQuerySnapshot` / `collectPartitions` 都经 `HudiScanPlanProvider.buildMetaClient` 重新构建 `HoodieTableMetaClient`。
+- **scan provider 与 metadata 割裂**:`HudiConnector.getScanPlanProvider` 每次返回 `new HudiScanPlanProvider`(`:136-138`),并在 `planScan`(`:148-151`)与 `getScanNodeProperties`(`:363`)各建一次自己的 metaClient,与 metadata 的完全独立。
+- **`metadataMemoizesLoadedTable = no`**:funnel 把「元数据对象」收敛为一条语句一个,但**没有**收敛重复的远程 table load。需要 iceberg 式的内部 memo(PERF-01/PERF-07 类比)才能把每 pass 5-6 次 metaClient 构建塌缩为一次。
+
+### 4. 热点重复加载审计 (Hot-path repeated-load audit)
+
+重活 = 构建 `HoodieTableMetaClient`(远程读 `.hoodie/hoodie.properties` + table config + 列举 active timeline 目录)+ schema 解析(`getTableAvroSchema` + `InternalSchema`)+ 分区列举(`listAllPartitionPaths` = `HoodieTableMetadata` 扫描)。对一条「带 WHERE 分区谓词、非 hive-sync 的分区 hudi SELECT」,单个 planning pass 内对**同一张表**的重复远程加载:
+
+| ID | area | 问题 | 倍数 | cost | 严重度 | memoized? | file:line |
+|---|---|---|---|---|---|---|---|
+| HD-P01 | query-plan | `HoodieTableMetaClient` 在每个 metadata+scan 入口各重建一次(getColumnHandles→getTableSchema、beginQuerySnapshot→latestInstant、applyFilter、MVCC getTableSchema、planScan、getScanNodeProperties),~5-6 次/pass;每次远程读 hoodie.properties + 列举 timeline。iceberg PERF-01/07 直接类比。 | ~5-6×/pass (B/D) | remote-io | **P1** | no | `HudiScanPlanProvider.java:148` |
+| HD-P02 | schema | 最新 Avro schema + InternalSchema 在独立 metaClient 上重复解析 ~4 次:`getSchemaFromMetaClient`(`:819-821`)供 getColumnHandles 与 3-arg MVCC getTableSchema;planScan(`:188,194,220`);getScanNodeProperties(`:382-383`)。 | ~4×/pass (B) | remote-io | **P1** | no | `HudiConnectorMetadata.java:797` |
+| HD-P03 | partitions | 分区列举(`listAllPartitionPaths` = `HoodieTableMetadata.create`+`getAllPartitionPaths`,元数据表扫描)+ `latestCompletedInstant` timeline 读,无 `(table,instant)` 缓存:collectPartitions 支撑 listPartitions/Names/Values(`:707-713`)、applyFilter 非 hive-sync 再列举(`:289-291`)、planScan resolvePartitions 再列举(`:281,648`);一次 MTMV refresh 内 fe-core 重列举 4-6 次。无 IcebergPartitionCache / hive partitionViewCache 类比。 | 3×/pass + 4-6×/MTMV (A/B) | remote-io | **P1** | no | `HudiScanPlanProvider.java:734` |
+| HD-P04 | schema | HMS 元数据走 **RAW** `ThriftHmsClient` — hudi **未包** `CachingHmsClient`(`HudiConnector.createClient:186`;对照 `HiveConnector.wrapWithCache`)。getTableHandle 的 tableExists+getTable(2 RPC,`:204-207`)、hive-sync 下 applyFilter(`:278`)与 collectPartitions(`:695`)各自 listPartitionNames,均每查询重打且跨查询无缓存。缓存类 `CachingHmsClient`(keyed `(db,table)`、24h TTL)已存在且被 hive 使用,hudi 热点却绕过 = Variant C。 | 2+ RPC/pass,跨查询无缓存 (C) | remote-io | P2 | no | `HudiConnector.java:186` |
+| HD-P05 | split-enum | 每 scan loop 不变量重算:`storageHadoopConfig(context)`(翻译全部 StorageProperties)+ `buildHadoopConf` 在 planScan(`:912-927`)与 getScanNodeProperties(`:341,363`)各建一遍;storage-URI 归一化 `context.normalizeStorageUri` 逐 base file / 逐 slice 调用(collectCowSplits `:445`、collectMorSplits `:474-476`),未按 iceberg PERF-06 每 scan 提升一次。 | 2×/pass(config)+ O(N_files)(uri)(D) | cpu | P2 | no | `HudiScanPlanProvider.java:912` |
+
+倍数与 cost 说明:HD-P01/P02/P03 是真远程 IO 且真倍数、真无 memo,属问题类 Variant B(单链重复 k 次)/D(跨入口未提升的 loop-invariant)/A(MTMV 循环放大);HD-P04 属 Variant C(缓存存在但热点未命中);HD-P05 只是 CPU/alloc,低危。已 skeptical 排除误报:per-file schemaId resolver 是真·逐文件(各文件自带写入 schema 版本),非可提升不变量,不计入。
+
+### 5. 授权 / 一致性 (Authz / consistency)
+
+- **不 vend 每用户凭证**:hudi 用 catalog 级单一身份 —— plugin 端 Kerberos `doAs`(单 keytab,`buildPluginAuthenticator:223-238`、`metaClientExecutor:102-122`)或 FE 注入的 `context.executeAuthenticated`(sibling 下为 NOOP/SIMPLE)。无 REST-OIDC / `session=user` 类比,无 per-identity metastore。
+- `HudiConnector` 未覆写 `getCapabilities`,继承空默认(无 `SUPPORTS_USER_SESSION`);hive gateway 前门守卫已保证不接入 session=user sibling(`HiveConnector:548-562` 的 iceberg 守卫,同一契约覆盖 hudi)。
+- **name-only 缓存是 authz-安全的**:所有用户看到同一 catalog-身份视图,与 hive 的共享 `CachingHmsClient` 一致。**不需要** iceberg Layer-3 的 `isUserSessionEnabled` 置空 / `shouldBypassSchemaCache`。
+- **写路径一致性:无**。hudi 只读(`beginTransaction` 抛错 `:395-398`;`getWritePlanProvider` 留 SPI 默认 null),不存在读+写共享 metadata/txn 的需求,无需 write-txn co-holder。
+
+### 6. 所需框架部件 (Framework pieces)
+
+- **per-statement-funnel-memoization = partial**:funnel 路由已在(一语句一 metadata 对象),但缺「语句内 resolved metaClient+schema memo」且 scan provider 独立建 metaClient。需 iceberg PERF-07 式:让 `HudiConnectorMetadata` 与 `HudiScanPlanProvider` 经 `ConnectorStatementScope` 共享同一 resolved metaClient/schema,把 5-6 次塌缩为一次。
+- **cross-query-table-cache = yes**:无 metaClient/table-config 跨查询缓存(IcebergTableCache 类比),按 basePath keyed(TTL + REFRESH 失效),塌缩 HD-P01。
+- **partition-cache = yes**:无 `(table,instant)` 分区缓存(IcebergPartitionCache / hive ConnectorPartitionViewCache 类比),解决 HD-P03(MTMV/SHOW PARTITIONS 重列举)。
+- **format-cache = no**:COW/MOR 从权威 table config/handle 读(`HudiScanPlanProvider:156`),`detectFileFormat` 是廉价后缀判断,无 iceberg getFileFormat whole-table planFiles 回退可 memo。
+- **comment-cache = no**:未覆写 getComment,无 information_schema/SHOW TABLE STATUS 的逐表远程 comment load。
+- **manifest-or-file-list-cache = partial**:planScan 每查询建 FileSystemView + 列 partition path(`:273-281`),无文件列举缓存(hive 有 HiveFileListingCache);可加,但优先级低于 metaClient/schema/partition memo。
+- **per-scan-hoist = yes**:planScan 与 getScanNodeProperties 各建 metaClient/重解析 schema(HD-P01/P02);storageHadoopConfig/buildHadoopConf 建两遍、normalizeStorageUri 逐文件(HD-P05)。每 scan 解析一次并复用。
+- **per-file-split-memo = no**:分区值已每分区解析一次并跨文件共享;per-file schemaId 是真·逐文件。仅剩 per-file uri-normalize 归入 per-scan-hoist。
+- **authz-session-user-isolation = no**:非 session=user,name-only 缓存不会泄漏。
+- **shared-fe-connector-cache-adoption = yes**:完全没用共享工具箱,也没包 `CachingHmsClient`。采用之(像 hive 一样 `wrapWithCache` 包 `CachingHmsClient`;分区用 `ConnectorPartitionViewCache`)是 HD-P03/P04 最低风险的底座,且与参考模式一致。
+- **write-txn-coholder = no**:只读。
+
+### 7. 结论与建议优先级 (Verdict + recommendations)
+
+hudi 已 LIVE(hms sibling),且是 SPI 连接器中缓存最薄的一个:**零** cross-query 元数据缓存、**零** per-statement metaClient/schema memo,甚至未包 `CachingHmsClient`。它已正确接入 Layer-2 funnel,但 funnel 只塌缩「元数据对象」,重远程加载(metaClient 5-6×/pass、schema ~4×、分区元数据表扫描 3×/pass 且 4-6×/MTMV)在每个入口重复。iceberg 框架模式**直接适用且必要**。建议按序:
+
+1. **per-statement resolved-metaClient+schema memo**(metadata 与 scan provider 共享)——消灭 HD-P01/HD-P02,旗舰改动(iceberg PERF-07/PERF-01)。
+2. **把 HMS client 包进 `CachingHmsClient`**——与 hive 对齐的一行改动,立即修 HD-P04(Variant C)。
+3. **cross-query metaClient/table 缓存 + `(table,instant)` 分区缓存(采用 `ConnectorPartitionViewCache`)**——修 HD-P03(MTMV/SHOW PARTITIONS 重列举)。
+4. **per-scan 提升** metaClient/schema/storage-config/uri-normalizer(HD-P05)。
+
+**不需要** authz 隔离(非 session=user)、**不需要** write-txn 工作(只读)。附带:清理散落的 stale `"dormant until hms enters SPI_READY_TYPES"` 注释。
+
+---
+
+## 复核结论 (adversarial verify)
+
+**Verdict: ADJUSTED** · 确认发现 IDs: HD-P01, HD-P02, HD-P03, HD-P04, HD-P05
+
+| 原始声明 | 问题 | 更正 | 证据 |
+|---|---|---|---|
+| authzConsistency: 'the hive gateway front-door guard already enforces that no session=user sibling can attach (HiveConnector:548-562, mirrored contract)' covering hudi. | The SUPPORTS_USER_SESSION fail-loud guard at HiveConnector.java:548-556 lives inside getOrCreateIcebergSibling and its error text is iceberg-specific ('iceberg-on-HMS sibling ... unexpectedly declares SUPPORTS_USER_SESSION'). getOrCreateHudiSibling (:576-591) has NO equivalent guard, so the '548-562 mirrored contract' does not literally cover the hudi sibling. | hudi's authz-safety instead rests on two verified facts: (a) HudiConnector does not override getCapabilities, so it inherits the empty default with no SUPPORTS_USER_SESSION (confirmed — the only getCapabilities hits are javadoc in HudiConnectorMetadata, no override); and (b) the hive front door itself is never session=user. The audit's substantive conclusion (name-only caches are authz-safe, no Layer-3 isolation / shouldBypassSchemaCache needed) is UNCHANGED and correct. | |
+| HD-P03 multiplier: '3x within one pass' partition metadata-table scans for a filtered partitioned SELECT. | For a FILTERED query the code deliberately dedups to ~1 listing: applyFilter (non-hive-sync) lists once at HudiConnectorMetadata.java:289-291, then resolvePartitions returns the prunedPaths and short-circuits WITHOUT calling listAllPartitionPaths (HudiScanPlanProvider.java:635-638). The connector's own comment at HudiConnectorMetadata.java:288 states 'a filtered query lists exactly once (here) instead of there.' So '3x within one pass' is an upper bound, not the single-pass norm. | The '3x' only materializes when fe-core independently invokes multiple of listPartitions / listPartitionNames / listPartitionValues (each re-calls collectPartitions -> a fresh HoodieTableMetadata scan, HudiConnectorMetadata.java:707-713) and/or on an UNFILTERED scan where resolvePartitions does list (HudiScanPlanProvider.java:648). The CORE finding — a metadata-table partition scan re-run at multiple entrypoints with NO (table,instant)-keyed cache — is CONFIRMED; only the per-pass count is query-shape-dependent. | |
+| HD-P01 multiplier '~5-6x per planning pass' and HD-P05 citation 'planScan.buildHadoopConf (:912-927)'. | Several metaClient build sites are conditional: applyFilter builds one only when a partition predicate is present AND non-hive-sync (:289-291); getScanNodeProperties builds one only when !force_jni (:363); the 3-arg MVCC getTableSchema fires only on the time-travel/MVCC path. HD-P05's '912-927' is the buildHadoopConf METHOD definition, not the planScan call site (the actual call is planScan:147). | Minimum unconditional metaClient builds per pass are ~3-4 (getColumnHandles->getSchemaFromMetaClient :800-801, beginQuerySnapshot->latestInstant :749-750, planScan :148-151, getScanNodeProperties :363), reaching 5-6 with the conditional sites — so '5-6x' is a valid upper bound. HD-P05 substance (buildHadoopConf/storageHadoopConfig built twice per pass at :147 and :363; normalizeNativeUri per-file at :445 and :476) is correct; only the line citation is loose. Neither adjustment weakens the 'not memoized' core. | |
+
+> 复核备注:Verified against HEAD aaab68ef474. All MATERIAL claims hold; corrections are precision-only and do not overturn any finding.
+
+MIGRATION STATUS — CONFIRMED. SPI_READY_TYPES = {jdbc, es, trino-connector, max_compute, paimon, iceberg, hms} (CatalogFactory.java:56-57), with the :50-55 comment explicitly forbidding 'hudi'. HudiConnectorProvider.getType() returns sibling-only 'hudi' (:45-47). Live path wired: HiveConnector.newMetadata passes this::getOrCreateHudiSibling (:190); HiveConnectorMetadata.getTableHandle diverts HUDI to hudiSiblingMetadata(session).getTableHandle (:415-416); getOrCreateHudiSibling builds via createSiblingConnector (:576-591). Legacy fe-core HudiScanNode/HudiExternalMetaCache/HMSExternalTable/HudiDlaTable ALL removed (find returns none) — confirms live, not dormant. Stale-comment conflict is REAL and correctly surfaced: 'dormant until hms enters SPI_READY_TYPES' / 'today getTableHandle is never called' present at HiveConnectorMetadata.java:410,1802,1942,1972,2007 and HudiConnectorMetadata.java:391 — all stale since hms IS in the set.
+
+CURRENT CACHES — all 4 CONFIRMED. (1) per-statement funnel memo via getOrCreateMetadata key 'metadata:'+catalogId+':'+HUDI_LABEL (HiveConnectorMetadata.java:302-304, hudiSiblingMetadata :332-333, by-handle siblingMetadata :347). (2) RAW ThriftHmsClient — HudiConnector.createClient returns new ThriftHmsClient (:186), NOT wrapped in CachingHmsClient; contrast HiveConnector which imports+uses CachingHmsClient/wrapWithCache (CachingHmsClient.java exists in fe-connector-hms). (3) pluginAuth double-checked memo (:196-206). (4) hudi-lib InternalSchemaCache.searchSchemaAndCache (HudiSchemaUtils.java:278).
+
+FUNNEL PARTICIPATION — CONFIRMED. Exactly one HudiConnectorMetadata per statement, but it memoizes NOTHING: fields are only {hmsClient:153, properties:154, metaClientExecutor:156, storageHadoopConfig:161} — grep for any HoodieTableMetaClient/InternalSchema/Schema/HoodieTimeline/partition/schema FIELD returns only method signatures, no cache field. Scan provider is a separate object (HudiConnector.getScanPlanProvider returns new HudiScanPlanProvider, :136-138) building its own metaClient in planScan (:148-151) and getScanNodeProperties (:363).
+
+HOT-PATH FINDINGS — all 5 independently CONFIRMED as real heavy remote-IO ops with genuinely NO memoization (no pre-existing cache catches them): HD-P01 (HoodieTableMetaClient.build reads .hoodie/hoodie.properties + loads active timeline, per line 916-917 comment; rebuilt at every metadata+scan entrypoint), HD-P02 (getTableAvroSchema(true) + resolveTableInternalSchema re-resolved at getSchemaFromMetaClient:819-821, planScan:188/194/220, getScanNodeProperties:382-383), HD-P03 (listAllPartitionPaths = HoodieTableMetadata.create+getAllPartitionPaths at :734-742, no (table,instant) cache), HD-P04 (raw ThriftHmsClient, Variant C — CachingHmsClient exists+used by hive but hudi bypasses it), HD-P05 (buildHadoopConf/storageHadoopConfig 2x + normalizeNativeUri per-file, CPU-only, correctly rated P2). The audit correctly EXCLUDED false positives (per-file schemaIdResolver at :232-241 is genuinely per-file, not a hoistable invariant; format-cache/comment-cache not applicable — getTableType is authoritative at :156, detectFileFormat is a cheap suffix check at :899-910, no getComment override). No severe finding was understated and no finding is covered by an existing cache. Overall verdict (hudi is the least-cached SPI connector; iceberg framework pattern applies and is needed) STANDS.
diff --git a/plan-doc/connector-cache-unification/connectors/jdbc.md b/plan-doc/connector-cache-unification/connectors/jdbc.md
new file mode 100644
index 00000000000000..24fab9d705b80f
--- /dev/null
+++ b/plan-doc/connector-cache-unification/connectors/jdbc.md
@@ -0,0 +1,90 @@
+## 连接器附录:jdbc(fe-connector-jdbc,catalog type `jdbc`)
+
+### 结论速览
+`jdbc` 是**关系型透传(relational passthrough)**连接器:FE 只负责元数据发现 + 生成远程 SQL,真正的数据扫描由 BE 的 `JdbcJniReader` 直接对远端库执行。它**没有** iceberg 那套 split / file / manifest / partition / snapshot 规划层,所以 iceberg 热路径缓存框架(cross-query Table/Partition/Format/Manifest cache)对它基本无对应目标。其昂贵远程元数据早已被 **fe-core 跨查询缓存**前置,连接池是 **per-catalog 单例**。审计结论:**不套用 iceberg 式连接器缓存**;只存在两处 P2 级冗余远程取列,适合用轻量 per-statement memo 收口。
+
+Migration:`jdbc` ∈ `SPI_READY_TYPES`(`CatalogFactory.java:57`),`JdbcConnectorProvider.getType()=="jdbc"`(`JdbcConnectorProvider.java:41`),经 SPI 路由到 `PluginDrivenExternalCatalog`。legacy fe-core `JdbcExternalCatalog/JdbcExternalTable` 已删;残留 fe-core `datasource/jdbc/client/JdbcClient` 仅服务 CDC/streaming-job 校验,不在 catalog 查询热路径。**LIVE**。
+
+### 缓存与记忆一览
+
+| 缓存 / 记忆 | 层 | scope | key | TTL / 失效 | file:line | 前置了哪个连接器远程调用 |
+|---|---|---|---|---|---|---|
+| HikariDataSource 连接池 | 连接器 | cross-query(per-catalog 单例) | 每 client 一池 | connector 生命周期,`close()` 销毁 | `JdbcConnectorClient.java:89` / `JdbcDorisConnector.java:188` | 所有远程 round-trip 的实际资源复用点 |
+| CLASS_LOADER_MAP 驱动 classloader | 连接器 | 进程级 cross-query | driver URL | 永不淘汰(986696 Metaspace 泄漏修复) | `JdbcConnectorClient.java:78,268` | 驱动加载 |
+| OceanBase compat-mode delegate | 连接器 | cross-query(per-client volatile) | 单 delegate | client 生命周期 | `JdbcOceanBaseConnectorClient.java:45,59` | `ob_compatibility_mode()` 方言探测(一次) |
+| ExternalSchemaCache | fe-core | cross-query | `SchemaCacheKey(table)` | expire-after-access + refresh/DDL | `ExternalMetaCacheMgr.java:365` / `ExternalTable.java:447` | `getTableSchema`(via `initSchema`,`PluginDrivenExternalTable.java:469`) |
+| ExternalRowCountCache | fe-core | cross-query | `RowCountKey(catalog,db,table)` | expireAfterWrite ~1 天,异步 | `ExternalRowCountCache.java:45` | `getTableStatistics`(via `fetchRowCount`,`:1133`) |
+| MetaCache 名录/对象 | fe-core | cross-query | db 名 / (db,table) 名 | expire-after-access + refresh | `metacache/MetaCache.java:65` | `listDatabaseNames`/`listTableNamesFromRemote`(`PluginDrivenExternalCatalog.java:293,310`) |
+
+**关键事实**:连接器模块(`fe-connector-jdbc`)自身**没有任何元数据缓存**(grep `Cache/memo/Caffeine/LoadingCache` 在连接器代码零命中)。上表前三行是资源/方言单例,后三行是 fe-core 缓存。`JdbcConnectorMetadata` 无状态,每次调用都原样转发 `JdbcConnectorClient` 做一次新的 JDBC 元数据 round-trip(`JdbcConnectorMetadata.java:119/162/144`)。
+
+### Funnel 参与
+
+- **已 funnel**:所有 fe-core 读 seam 经 `PluginDrivenMetadata.get` 取 metadata(`initSchema:449`、`fetchRowCount:1126`、`getComment:923`、`PluginDrivenScanNode` 的 `metadata()`、`JdbcQueryTableValueFunction:53`)。
+- **metadata 不 memo loaded table**:`JdbcConnectorMetadata` 只持 `client+properties` 引用,`getTableSchema/getColumnHandles/getTableStatistics` 每次都远程取,内部无 memo。JDBC 也**没有**可缓存的昂贵 `Table` 对象。
+- **funnel 对 JDBC 收益有限**:funnel 保证「每语句一个 metadata 实例」,但该实例构造近乎零成本,真正昂贵的连接池已是 per-catalog 单例;funnel 唯一有用之处是提供了一个活的 per-statement scope,可用来 memo `getColumnHandles`——但目前没用上。cross-statement 缓存加载器(`buildCrossStatementSession`,`:1153`)显式用 `ConnectorStatementScope.NONE`,把 schema/rowcount/名录的跨查询缓存契约固化,合理。
+
+### 热路径重复取数审计
+
+| # | 入口 | 重复取的信息 | 倍率 | 代价 | 已 memo | 严重度 | file:line |
+|---|---|---|---|---|---|---|---|
+| HP-1 | scan 规划 `buildColumnHandles` | `getColumnHandles`→`getJdbcColumnsInfo`(远程 `DatabaseMetaData.getColumns`;MySQL 追加 information_schema) | 每 scan node ~1-2 次(getSplits/startSplit 二选一 + getOrLoadPropertiesResult) | remote-io | 否 | **P2** | `JdbcConnectorMetadata.java:162`;`PluginDrivenScanNode.java:1917` |
+| HP-2 | write 整形 `buildInsertSql` | `new JdbcConnectorMetadata(...)`(**绕开 funnel**)→`getColumnHandles`→远程取列 | 每 INSERT 1 次;EXPLAIN INSERT 2 次 | remote-io | 否 | **P2** | `JdbcWritePlanProvider.java:136` |
+| HP-3 | SHOW/DESC `getComment` | `getTableComment` 无缓存直连(MySQL 远程 INFORMATION_SCHEMA) | 每 SHOW/DESC 1 次(非查询规划热路径) | remote-io | 否 | P2 | `PluginDrivenExternalTable.java:925` |
+| HP-4 | split 枚举 `planScan` | (阴性)零远程 IO,恒 1 个 scan range,纯 SQL 拼装 | 1(常量) | cpu | 是 | none | `JdbcScanPlanProvider.java:66,152` |
+| HP-5 | row-count | (阴性)`getTableStatistics` 被 ExternalRowCountCache 吸收;base 返回 -1 | 缓存命中后 0 | remote-io | 是 | none | `PluginDrivenExternalTable.java:1133` |
+
+**分析**:HP-1/HP-2 是同一根问题的两面——列句柄解析(local→remote 名映射)在扫描期和写整形期各自重新远程取列,而这些列名+remote 名早已随 `getTableSchema` 进了 `ExternalSchemaCache`(两者底层同为 `getJdbcColumnsInfo`)。这是 problem-class 的 **variant C(缓存存在但热路径绕过)**。但要克制:`getJdbcColumnsInfo` 是一次「cheap-ish」的 JDBC 元数据 round-trip,倍率是 per-scan-node / per-write(非 per-split/per-file——JDBC 恒 1 split),绝对成本与放大都远小于 iceberg 的 manifest/planFiles。故 **P2**。HP-2 额外坏在自构实例、连 funnel 记忆的实例都不复用。
+
+### Authz / 一致性
+
+JDBC 用 **catalog 固定 user/password**(`JdbcConnectorProperties.USER/PASSWORD`),烘焙进 HikariCP 与 BE 的 `TJdbcTable`。**无 per-user 凭证、无 SUPPORTS_USER_SESSION**(`JdbcDorisConnector.java:102-105 只有 PASSTHROUGH_QUERY + METADATA_PRELOAD`),无 kerberos doAs / REST OIDC / per-identity metastore。所有身份共享同一远程用户 → name-only 缓存**不会**泄漏 can-LIST-cannot-LOAD 式 per-user 元数据,不需要 iceberg 的 `isUserSessionEnabled()` 缓存置空或 `shouldBypassSchemaCache` 分片。写侧 BE 逐行 auto-commit、`beginTransaction` 返回 `NoOpConnectorTransaction`(`JdbcConnectorMetadata.java:286`),读写无需共享 txn/snapshot。
+
+### 框架各件需求判定
+
+| 框架件 | 需要? | 理由 |
+|---|---|---|
+| per-statement-funnel-memoization | **partial** | 唯一真适用件:HP-1/HP-2 的 `getColumnHandles` 冗余远程取列。补救 = 在活 scope 上 memo,或(更优)让 fe-core `buildColumnHandles` 直接从已缓存的 `PluginDrivenSchemaCacheValue` 派生列句柄。P2,低优先。 |
+| cross-query-table-cache | no | 无昂贵 loaded Table;schema/rowcount/名录已由 fe-core 跨查询缓存前置。 |
+| partition-cache | no | 无分区,恒 1 scan range。 |
+| format-cache | no | 无文件格式解析(关系型)。 |
+| comment-cache | no | HP-3 存在但仅 SHOW/DESC,非查询热路径,价值边际。 |
+| manifest-or-file-list-cache | no | 无 manifest/文件清单;无 file-list row-count 路径。 |
+| per-scan-hoist | no | planScan 已 O(1) 零远程 IO,无 loop-invariant 可外提。 |
+| per-file-split-memo | no | 恒 1 split,无 per-file 不变量。 |
+| authz-session-user-isolation | no | 固定凭证,name-only 缓存不泄漏。 |
+| shared-fe-connector-cache-adoption | no | 连接器无跨查询元数据缓存可迁移。 |
+| write-txn-coholder | no | BE auto-commit,无 FE 读写共享 txn。 |
+
+### 优先级建议
+
+1. **(可选,P2)** 收口 HP-1/HP-2 的列句柄冗余远程取:最干净的做法是**在 fe-core 让 `buildColumnHandles`/写路径从已跨查询缓存的 `PluginDrivenSchemaCacheValue`(已含 columns + remote 名)派生列句柄**,对所有连接器都零远程 IO——这是框架级小改,而非 jdbc 专属缓存;退一步可在 `JdbcConnectorMetadata` 上按 `session.getStatementScope()` memo `getJdbcColumnsInfo` 结果。收益小、风险低,非阻塞。
+2. **不新增任何 iceberg 式连接器缓存**(Rule 2 简单优先):JDBC 无对应的重复 loadTable/manifest/planFiles 目标,新增缓存纯属投机且会引入与固定凭证无关的失效/一致性负担。
+3. HP-3(comment)可暂不处理——off hot-path。
+
+Confidence:high(全部锚定 HEAD file:line;e2e 未跑,判定基于代码结构与调用图)。
+
+---
+
+## 复核结论 (adversarial verify)
+
+**Verdict: CONFIRMED** · 确认发现 IDs: HP-1, HP-2, HP-3, HP-4, HP-5
+
+| 原始声明 | 问题 | 更正 | 证据 |
+|---|---|---|---|
+| jdbc 在 CatalogFactory.SPI_READY_TYPES 中 (CatalogFactory.java:57) | Line-number anchor slightly off. The claim itself is TRUE, but the ImmutableSet containing "jdbc" is not at line 57. | At HEAD the declaration `private static final Set SPI_READY_TYPES =` is on line 56 and the `ImmutableSet.of("jdbc", "es", "trino-connector", "max_compute", "paimon", "iceberg", "hms")` literal is on line 63-64 of fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java. Membership is confirmed either way. | |
+| ExternalSchemaCache TTL = expire-after-access(external_cache_expire_time_minutes_after_access) | Config key name imprecise. | The actual Config field seen at ExternalRowCountCache.java:46 is `external_cache_expire_time_seconds_after_access` (seconds, not minutes) and `external_cache_refresh_time_minutes`. This is a cosmetic naming imprecision; the expire-after-access + refresh semantics claimed are correct. | |
+| ExternalSchemaCache file: ExternalMetaCacheMgr.java:365 | Anchor points to the accessor, not a distinct cache class. | Line 365 of ExternalMetaCacheMgr.java is `public Optional getSchemaCacheValue(ExternalTable table, SchemaCacheKey key)` — the fe-core schema-cache accessor. Valid anchor for the mechanism; the schema-cache front-loading of getTableSchema is real and confirmed (initSchema at PluginDrivenExternalTable.java:449→getTableSchema :469). | |
+
+> 复核备注:All material claims hold at HEAD (aaab68ef474). Verified: (1) Migration — jdbc live/SPI-routed, legacy fe-core Jdbc catalog/table deleted, fe-core JdbcClient only for CDC/streaming validators. (2) All 6 caches exist at ~cited locations, no invented classes: CLASS_LOADER_MAP (JdbcConnectorClient.java:78), HikariDataSource per-catalog (:89 field/:220 new/getOrCreateClient :188 single-client guard), OceanBase volatile delegate double-checked (JdbcOceanBaseConnectorClient.java:45/59), fe-core ExternalSchemaCache/ExternalRowCountCache(:40-45 AsyncLoadingCache)/MetaCache(:65). (3) Funnel: JdbcConnectorMetadata is stateless-forwarding (getTableSchema:119, getTableStatistics:144, getColumnHandles:162, getTableComment:252 all fresh client round-trips) — metadataMemoizesLoadedTable=no CONFIRMED; write path new JdbcConnectorMetadata at JdbcWritePlanProvider.java:136 bypasses funnel CONFIRMED.
+
+Hot-path findings independently confirmed:
+- HP-1 CONFIRMED: buildColumnHandles() (PluginDrivenScanNode.java:1916) → metadata.getColumnHandles → client.getJdbcColumnsInfo (real remote DatabaseMetaData.getColumns at JdbcConnectorClient.java:408). Callers: getSplits(:1263), startSplit(:1587,:1672), getOrLoadPropertiesResult(:1841) → 1-2 remote round-trips per scan node, NOT memoized, distinct from ExternalSchemaCache-fronted getTableSchema path. Variant C. Multiplier accurate.
+- HP-2 CONFIRMED: buildInsertSql (JdbcWritePlanProvider.java:130) constructs `new JdbcConnectorMetadata(client, properties).getColumnHandles(...)` at :136 — fresh instance bypassing funnel + remote refetch. Called from planWrite(:68) and appendExplainInfo(:119).
+- HP-3 CONFIRMED (minor/off-hot-path, as audit states): getComment (PluginDrivenExternalTable.java:925) → metadata.getTableComment uncached; base returns "" but MySQL override does remote INFORMATION_SCHEMA.TABLES query (JdbcMySQLConnectorClient.java:165). Only SHOW/DESC path.
+- HP-4 CONFIRMED (negative control): planScan (JdbcScanPlanProvider.java:75) is pure SQL-string assembly + property reads, zero client/remote calls, returns Collections.singletonList(scanRange); estimateScanRangeCount()==1 (:153). No split/file/partition loop.
+- HP-5 CONFIRMED (negative control): base getRowCount returns -1 (:467), MySQL override remote (:191); fronted cross-query by ExternalRowCountCache.loadRowCount→table.fetchRowCountWithMetaCache (ExternalRowCountCache.java:90-94).
+
+Authz CONFIRMED: getCapabilities = EnumSet.of(SUPPORTS_PASSTHROUGH_QUERY, SUPPORTS_METADATA_PRELOAD) (JdbcDorisConnector.java:98-105), no SUPPORTS_USER_SESSION; buildConnectorSession injects credential only withUserSessionCapability(supportsUserSession()) which is false for jdbc; shouldBypassSchemaCache/shouldBypassTableNameCache gated on same (PluginDrivenExternalCatalog.java:1177-1212). Fixed catalog user/password → no per-user metadata divergence, no leak. beginTransaction returns NoOpConnectorTransaction (:286).
+
+No missed severe finding: no per-split/per-file/per-partition amplification exists (structurally 1 scan range), names via MetaCache, schema/rowcount cross-query cached — audit's max severity P2 is appropriate. frameworkPiecesNeeded (partial funnel-memoization, no to all iceberg-style caches) and overallVerdict (do not apply iceberg cache framework, Rule 2) are well-supported. Corrections listed are cosmetic line-anchor/config-name nits that do not affect any conclusion.
diff --git a/plan-doc/connector-cache-unification/connectors/maxcompute.md b/plan-doc/connector-cache-unification/connectors/maxcompute.md
new file mode 100644
index 00000000000000..19d33b38b4c9ea
--- /dev/null
+++ b/plan-doc/connector-cache-unification/connectors/maxcompute.md
@@ -0,0 +1,95 @@
+## 连接器附录:maxcompute(`fe-connector-maxcompute`,catalog type `max_compute`)
+
+### 1. 迁移状态(Migration status)
+
+`max_compute` **已在** `SPI_READY_TYPES`(`CatalogFactory.java:57`),因此 MaxCompute catalog 走 SPI plugin 路径,被包成 `PluginDrivenExternalCatalog` + `MaxComputeDorisConnector`(`CatalogFactory.java:110-118`)。fe-core 中已无任何遗留 MaxCompute 类(`find fe/fe-core -iname '*MaxCompute*'` 为空);仅剩显示兼容用的 engine-name switch(`PluginDrivenExternalTable.java:1227-1230,1260-1261`)与 GSON 迁移引用。**读路径完全翻闸、live。** 写路径也已 live 接线(`PhysicalPlanTranslator.visitPhysicalConnectorTableSink` → `getWritePlanProvider(handle)` → `planWrite`);`MaxComputeConnectorMetadata.beginTransaction:332` / `MaxComputeWritePlanProvider:74` 里"gate-closed / dormant until cutover"的 javadoc 相对 HEAD 的 `SPI_READY_TYPES` **已过时**。
+
+liveness = **live**。
+
+### 2. 现有缓存(Current caches)
+
+| 缓存 | 作用域 | Key | TTL / 容量 | 失效 | file:line |
+|---|---|---|---|---|---|
+| `MaxComputePartitionCache` | cross-query | `(db, table)`;project 每 catalog 恒定,不入 key(`PartitionKey:138`) | 600s / 10000,可经 `meta.cache.max_compute.partition.*` 覆盖(`CacheSpec.fromProperties:92`) | REFRESH TABLE/DB/CATALOG + 分区增删 → 整表 flush(`invalidateTable:113`/`invalidateDb:118`/`invalidateAll:123`;connector 钩子 `MaxComputeDorisConnector.java:189/198/204/213`) | `MaxComputePartitionCache.java:60-173`;`final` 挂在长寿命 connector 上(`MaxComputeDorisConnector.java:61,79-80`) |
+| ODPS `Table` 对象内 lazy memo | per-scan | 单个 handle 携带的一个 `com.aliyun.odps.Table`(transient,`MaxComputeTableHandle.java:37`) | handle 生命周期;首次访问 `reload()` 后 in-object 缓存 | 随 handle GC | `getTableHandle:124`(`getOdpsTable` 惰性、无 RPC);首次元数据访问 reload(`MaxComputeScanPlanProvider.java:187,189,194`)。**不跨 handle 共享** |
+| fe-core `SchemaCacheValue` | cross-query | fe-core ExternalTable schema 缓存(每表) | fe-core 外表元缓存 TTL / REFRESH | REFRESH | `PluginDrivenExternalTable.initSchema:430-471`;`getTableSchema` 每 schema-cache 刷新一次,非每查询 |
+| `PluginDrivenScanNode.cachedMetadata` + `resolvedScanProvider` | per-scan | 每 scan node;metadata 按 catalogId,provider 按 handle identity | scan node 生命周期 | — | `PluginDrivenScanNode.java:189,184,245-260`(fe-core 通用设施) |
+| `EnvironmentSettings`/`SplitOptions`/providers | cross-query | 每 connector 一次性 lazy init(配置,非远端数据) | connector 生命周期 | — | `MaxComputeDorisConnector.doInit:94-128`;`MaxComputeScanPlanProvider.initFromProperties:117-161` |
+
+连接器**没有**:跨查询 table/schema 缓存(依赖 fe-core `SchemaCacheValue`)、per-statement handle memo、format 缓存(N/A)、comment 缓存(default 空)、manifest/file-list 缓存(N/A)。分区缓存用的是共享 `fe-connector-cache`(`CacheSpec`+`MetaCacheEntry`),但未用更高层的 `ConnectorPartitionViewCache`/`PartitionViewCacheKey`(那是分区派生视图的另一议题)。
+
+### 3. Funnel 参与(Layer-2)
+
+**已接入 funnel:是。** `MaxComputeConnectorMetadata` 只经 `MaxComputeDorisConnector.getMetadata(session)`(`MaxComputeDorisConnector.java:177-181`)产生,而它被 `PluginDrivenMetadata.get` → `scope.getOrCreateMetadata("metadata:"+catalogId)`(`PluginDrivenMetadata.java:70`)按 (statement, catalog) memo,故一条语句只有一个 `MaxComputeConnectorMetadata`(由 `tools/check-fecore-metadata-funnel.sh` 全构建强制)。
+
+**但该 metadata 不 memoize 已加载表:否。** `getTableHandle`(`MaxComputeConnectorMetadata.java:118-130`)每次都重跑 `structureHelper.tableExist`(line 121 → ODPS `tables().exists()` 远程探测,`McStructureHelper.java:129-137/218-225`)**并**新建一个惰性 `Table`(line 124)。`getMetadata` 本身极廉价(只包已 init 的 `odps`/`structureHelper`/`partitionCache`),所以对 MaxCompute 而言 funnel 的 metadata memo **省不掉任何远程 IO**。funnel 收敛的是"metadata 构造",不是"表解析"——metadata 内无 `(db,table)→handle` 映射,于是一条语句里 ~13 个 `resolveConnectorTableHandle` 站点(`PluginDrivenExternalTable.java:160,463,607,717,821,882,955,1026,1061,1128` + `resolveWriteCapabilityHandle` 205/222/375/410)与 translator 的 `getTableHandle`(`PhysicalPlanTranslator.java:624,676`;`BindSink.java:675,714`)各自付一次 `exists()` 探测;每个访问 schema 的路径各 reload 一次新 `Table`。**这正是 iceberg PERF-07 的缺口,在 MaxCompute 上尚未处理。**
+
+metadataMemoizesLoadedTable = **no**。
+
+### 4. 热路径重复加载审计(问题类应用)
+
+| # | 区域 | 问题 | 乘数 | 成本 | 严重度 | 已 memo | file:line |
+|---|---|---|---|---|---|---|---|
+| MC-1 | schema | `getTableHandle` 每次解析都发一次**冗余**的 ODPS `tables().exists()` 远程探测(`:121`→`McStructureHelper.tableExist`),且 connector metadata 与 funnel 均不 memo handle。一条语句在多个独立站点(scan 翻译、分区裁剪、row-count、逐列 stats、写能力探测)各解析一次 handle,各付一次 `exists()`。表本就是已解析的 catalog `ExternalTable`,此探测纯属浪费(变体 B 单链重复 k 次 + C funnel 只 memo metadata 不 memo handle)。 | k = 每语句触达的解析站点数;暖 fe-core stats 缓存下的分区 SELECT ≈2(scan create + `getNameToPartitionItems`),冷 stats 缓存/写路径更高 | remote-io | **P1** | 否 | `MaxComputeConnectorMetadata.java:118-130`;`McStructureHelper.java:129-137,218-225` |
+| MC-2 | schema | handle 不按语句 memo → 每个新 handle 携带各自未加载的 `Table`,每个访问 schema 的路径(`planScan` 的 `checkOperationSupported`/`getFileNum`/`getSchema`,冷 schema-cache 时的 `initSchema.getTableSchema`)各触发一次 `Table.reload()`。放大温和(多数非 scan 路径只用 db/table 名,不碰 `Table` 对象),约每语句 1–2 次 reload,而非 iceberg 的 3–7×(变体 B)。 | ~1–2×/语句 | remote-io | P2 | 是(per-handle in-object,不跨 handle 共享) | `MaxComputeConnectorMetadata.java:124`;`MaxComputeScanPlanProvider.java:183-194` |
+| MC-3 | split-enum | **非问题/已干净**:split 枚举无 per-split 远程放大。读会话只建一次(`buildBatchReadSession`,规划固有成本),循环前只序列化一次(`serializeSession:329`),per-split 循环仅构造 `MaxComputeScanRange` 并按引用共享同一序列化串(`337-372`)。`getSplits` 只调一次 `planScan`。 | 1×(无放大) | cpu | none | 是 | `MaxComputeScanPlanProvider.java:326-377` |
+| MC-4 | partitions | **数据侧非问题,handle 侧并入 MC-1**:分区列举(`listPartitions`/`listPartitionNames`/`listPartitionValues` + fe-core `getNameToPartitionItems`/`getNameToPartitionValues`)由跨查询 `MaxComputePartitionCache`(TTL 600s)兜住,per-table `getPartitions()` 不会每查询重发;仅 `getNameToPartitionItems` 里的 `getTableHandle` `exists()` 探测未缓存(已计入 MC-1)。`listPartitions` 故意忽略下推 filter(与遗留 SHOW PARTITIONS 一致)——全量列举但已缓存。 | 每 TTL 窗口每 (db,table) 1 次远程 `getPartitions` | remote-io | none | 是 | `MaxComputeConnectorMetadata.java:239-296`;`MaxComputePartitionCache.java:107` |
+
+### 5. Authz / 一致性
+
+- **perUserCredential = false,sessionUserRelevant = false。** MaxCompute 用 **catalog 级静态凭证**(AK/SK、RAM-Role-ARN、ECS-RAM-Role),CREATE CATALOG 时固化,`doInit` 里一次性建客户端(`MaxComputeDorisConnector.java:102`;`MCConnectorClientFactory.java:86-127`)。无 per-user 凭证下发、无 session=user、无 REST/OIDC 会话凭证、无 kerberos doAs、无 per-identity metastore——一个 catalog 的所有用户共享同一 ODPS 身份。
+- 因此 **Layer-3 授权缓存隔离不适用**:name-only 的 `MaxComputePartitionCache`(db+table)不会泄漏 can-LIST-cannot-LOAD 用户的元数据;MC-1 的 per-statement handle memo 同样授权安全(一语句=一身份,已由 `PluginDrivenMetadata.java:63-69` 钉住)。`tools/check-authz-cache-sharding.sh` 适用但 MaxCompute 天然合规。
+- 元数据陈旧有界且正确性安全:分区新增 ≤600s 内可见(与遗留 TTL 一致),删除至多陈旧一个 TTL,miss 时自愈。
+- **写一致性**:MaxCompute **非 MVCC**(用基类 `PluginDrivenExternalTable`,非 `MvccTable`),无需 read+write snapshot 对齐;写正确性只需把 ODPS 写会话绑到 per-statement `MaxComputeConnectorTransaction`(`setWriteSession`)、block 分配按 `txn_id`(`MaxComputeConnectorTransaction.java:61-131`)——已具备。
+
+### 6. 框架各件是否需要
+
+| 件 | 需要 | 理由 |
+|---|---|---|
+| per-statement-funnel-memoization | **yes(当前 partial)** | 最高价值。funnel 已给每语句一个 metadata,但 `getTableHandle` 每次重探存在性+新建惰性表。在 per-statement `MaxComputeConnectorMetadata` 实例里加 `Map<(db,table),MaxComputeTableHandle>`,让 `getTableHandle` 整条语句返回同一 handle(一次 `exists()`、一次 `reload()`),一举收敛 MC-1、MC-2。比 iceberg 的 scope-keyed load memo 更简单——metadata 实例本就 per-statement。并顺手去掉已解析读的冗余 `tableExist()` 探测。 |
+| cross-query-table-cache | partial | fe-core `SchemaCacheValue` 已跨查询缓存 schema,跨查询主需求已覆盖。连接器侧 cached ODPS `Table`(iceberg `IcebergTableCache` 类比,24h/REFRESH)可再省每查询 `planScan` reload(MC-2 跨查询维度),但有陈旧/TTL 代价、收益温和。可选、优先级低于 per-statement memo。 |
+| partition-cache | already-has | `MaxComputePartitionCache` 已是完整跨查询分区缓存(db+table,TTL 600s,容量 10000,REFRESH 失效),MC-4 证实其兜住热点分区读。 |
+| format-cache | no | N/A:MaxCompute 走 ODPS Storage API(arrow),无 per-table 文件格式推断(无 `getFileFormat`/`planFiles` 兜底),iceberg PERF-03 不存在。 |
+| comment-cache | no | 未 override `getTableComment`,SPI default 返回 `""`(`ConnectorTableOps.java:336`)无远程,无需缓存。 |
+| manifest-or-file-list-cache | no | N/A:无 manifest/snapshot 模型;读会话/split assigner 是每查询规划固有成本;`estimateDataSizeByListingFiles`/`listFileSizes` 未 override(default -1/空)。 |
+| per-scan-hoist | already-has | scan 不变配置(`EnvironmentSettings`/`SplitOptions`/超时)已提到 connector/provider 一次性 lazy init;序列化会话在 split 循环前算一次。iceberg PERF-06 per-scan storage-URI normalizer 不适用(无 per-file 下发存储)。 |
+| per-file-split-memo | no | N/A:split 只带 index/row-offset + 一份共享序列化会话串(已按引用共享),无 per-file 分区 JSON/值/删除载体需 memo。 |
+| authz-session-user-isolation | no | catalog 静态凭证、单一共享 ODPS 身份;name-only 缓存不会跨用户泄漏或供陈旧授权,无需 iceberg 的 `isUserSessionEnabled` 置空/`shouldBypassSchemaCache`。 |
+| shared-fe-connector-cache-adoption | already-has | `MaxComputePartitionCache` 基于共享 `CacheSpec`+`MetaCacheEntry`(`:20-21,92-97`),镜像 `CachingHmsClient`/`HiveFileListingCache`。 |
+| write-txn-coholder | already-has | `MaxComputeConnectorTransaction` 持 per-statement 写态(写会话/标识/settings/`TMCCommitData`/block 高水位),`planWrite.setWriteSession` 绑定、`txn_id` 提交;非 MVCC 无需 snapshot 共享;funnel 的一语句一写事务已协调。 |
+
+### 7. 结论与优先建议
+
+**结论:live,且已相当好地缓存——不是 iceberg 那样的 P0 目标。** 具备跨查询分区缓存(共享工具箱)、干净的 split 枚举(无 per-split 远程放大)、per-statement 写事务,并复用 fe-core 跨查询 schema 缓存;非 MVCC、静态凭证,Layer-3 授权隔离不适用。
+
+**唯一真正适用的框架件是 per-statement handle/table memoization:** `getTableHandle` 每次解析都发冗余的 ODPS `tables().exists()` 并返回新惰性表,而 funnel 只 memo metadata 不 memo handle——于是一条语句里 k 个独立解析站点各付一次 `exists()` RPC(MC-1,P1),访问 schema 的路径重复 reload 表(MC-2,P2)。
+
+**建议(低风险、高杠杆):** 在 per-statement `MaxComputeConnectorMetadata` 实例内按 `(db,table)` memo 解析出的 `MaxComputeTableHandle`,并对已解析读去掉冗余存在性探测;可选地后续再加跨查询 ODPS `Table` 缓存。改动远小于 iceberg 那套 buildout。
+
+---
+
+## 复核结论 (adversarial verify)
+
+**Verdict: CONFIRMED** · 确认发现 IDs: MC-1, MC-2, MC-3, MC-4
+
+| 原始声明 | 问题 | 更正 | 证据 |
+|---|---|---|---|
+| find fe/fe-core -iname '*MaxCompute*' returns nothing | Literally false: it returns two compiled dirs fe/fe-core/target/classes/org/apache/doris/datasource/maxcompute and target/test-classes/.../maxcompute (stale build artifacts). | The SOURCE tree fe/fe-core/src is clean (no MaxCompute .java). The read/write cutover conclusion is unaffected; only the stated grep evidence is imprecise. Verify with: find fe/fe-core/src -iname '*MaxCompute*' (empty). | |
+| PluginDrivenScanNode.cachedMetadata + resolvedScanProvider ... PluginDrivenScanNode.java:189/184/245-260 | File path implied by the funnel narrative (datasource/plugin/) is wrong. | PluginDrivenScanNode is at fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java. All cited line numbers are correct there: currentHandle:152, resolvedScanProvider:184, cachedMetadata:189, metadata():203, resolveScanProvider():245-260. | |
+| ~13 resolveConnectorTableHandle / getTableHandle sites (PluginDrivenExternalTable.java:160,463,607,717,821,882,955,1026,1061,1128 + resolveWriteCapabilityHandle 205/222/375/410) | Direct resolveConnectorTableHandle count is 10, not 13; the extra 205/222/375/410 line refs point at resolveWriteCapabilityHandle callers which I did not individually confirm (the method definition is at 187). | Exactly 10 direct resolveConnectorTableHandle call sites confirmed at the listed lines, each in a distinct real planning entrypoint (getSyntheticScanPredicates:152, initSchema:430, getShowCreateTableDdl:599, fetchSyntheticWriteColumns:700, getNameToPartitionItems:807, getNameToPartitionValues:870, getSupportedSysTables:946, getColumnStatistic:1014, getChunkSizes:1049, fetchRowCount:1121). The write-capability probes add a few more. The 'k independent sites, none memoized' core of MC-1 holds regardless of the exact count. | |
+| MC-2: ~1-2 reloads per statement | Slight understatement risk on the cold per-column stats path was checked and cleared, worth recording. | getColumnStatistic (fe-core:1014-1035) resolves a FRESH handle per column (so MC-1 exists()-probe amplifies to N-per-column on cold stats), BUT MaxComputeConnectorMetadata does NOT override getColumnStatistics (SPI default no-op, no remote call and no odpsTable access), so it triggers NO extra Table.reload(). MC-2's ~1-2x reload multiplier therefore holds; the per-column amplification is exists()-probe only, which the audit already flags qualitatively under MC-1 ('higher on cold stats caches'). | |
+
+> 复核备注:All material claims hold against HEAD (aaab68ef474). Core thesis verified: MaxCompute is LIVE and reasonably cached (not a P0 target like iceberg), and the one real applicable framework piece is per-statement handle/table memoization.
+
+CONFIRMED in code:
+- Migration: max_compute in SPI_READY_TYPES (CatalogFactory.java:57); write path live (PhysicalPlanTranslator getTableHandle:676, getWritePlanProvider:687); dormant-until-cutover javadoc at beginTransaction is stale.
+- Funnel: PluginDrivenMetadata.get memoizes ONE metadata per (statement,catalog) (line 70). getMetadata (MaxComputeDorisConnector:177-181) returns a fresh instance; MaxComputeConnectorMetadata carries no instance-level handle map (only odps/structureHelper/defaultProject/endpoint/quota/properties/partitionCache). getTableHandle (118-130) re-runs tableExist (line 121 -> tables().exists(), remote per ODPS SDK) + fresh lazy getOdpsTable (124). resolveConnectorTableHandle (PluginDrivenExternalTable:116-119) does NOT memoize -> each of 10 call sites re-resolves. => metadataMemoizesLoadedTable = no, confirmed.
+- MC-1 (P1): CONFIRMED real, not memoized at any layer, multiplier honest (~2 warm, per-column on cold stats).
+- MC-2 (P2): CONFIRMED real; per-handle in-object memo (MaxComputeTableHandle.java:37 transient), not cross-handle; reload sites MaxComputeScanPlanProvider 183/187/189/194; ~1-2x correct.
+- MC-3: CONFIRMED valid non-finding. serializeSession once (329), per-split loop (337-372) shares the one serialized string by reference (.scanSerialize at 346/364); no per-split remote IO.
+- MC-4: CONFIRMED valid non-finding. All three partition SPI methods use partitionCache.getPartitions (listPartitionNames:243, listPartitions:265, listPartitionValues:284); the only uncached cost is the getTableHandle exists() probe, correctly folded into MC-1.
+- MaxComputePartitionCache: cross-query, keyed (db,table) PartitionKey:138, TTL 600s (74), cap 10000 (75), CacheSpec.fromProperties (92), built on shared fe-connector-cache (imports 20-21), REFRESH hooks 189/198/204/213 including whole-table flush on invalidatePartition:213.
+- Authz: static catalog credentials (MCConnectorClientFactory:86-127 AK/SK / RAM-Role-ARN / ECS-RAM-Role), no per-user/session=user; Layer-3 isolation N/A; name-only cache authz-safe under one shared ODPS identity.
+- frameworkPiecesNeeded all consistent: getTableComment default returns "" (ConnectorTableOps.java:336) and is NOT overridden -> comment-cache 'no' correct; write-txn-coholder present (MaxComputeConnectorTransaction:61-131, setWriteSession:97, TMCCommitData/nextBlockId).
+
+No hallucinated caches, no invented classes, no over-stated multipliers, and no missed severe finding. Only immaterial nits (see corrections): the 'find returns nothing' grep evidence, the PluginDrivenScanNode directory path, and the '~13 sites' count. None change any conclusion.
diff --git a/plan-doc/connector-cache-unification/connectors/paimon.md b/plan-doc/connector-cache-unification/connectors/paimon.md
new file mode 100644
index 00000000000000..5f0cde58af4297
--- /dev/null
+++ b/plan-doc/connector-cache-unification/connectors/paimon.md
@@ -0,0 +1,86 @@
+## 附录:fe-connector-paimon 热点路径与缓存审计
+
+### 一、迁移状态(Migration status)
+
+`paimon` 已在 `SPI_READY_TYPES` 白名单内(`fe/fe-core/.../datasource/CatalogFactory.java:57`),是一个**一等公民独立 catalog 类型**,经 SPI plugin 路径 `PaimonConnectorProvider → PaimonConnector → PaimonConnectorMetadata / PaimonScanPlanProvider`,由 `PluginDrivenExternalCatalog` 承载。它是 **LIVE**,不是 sibling:读、MVCC/time-travel、DDL(建/删库+表)、system tables、stats、SHOW CREATE 均在连接器落地。**写路径故意未迁移**(`getCapabilities` 不声明写能力,`PaimonConnector.java:318`),因此本连接器无 beginWrite/commit。
+
+关键结构性事实:生产 catalog 被 **paimon SDK `CachingCatalog`** 包裹(`CatalogFactory.createCatalog → CachingCatalog.tryToCreate`,`CACHE_ENABLED` 默认 true,已 javap 证实 paimon-core 1.3.1)。`CachingCatalog` 自带 `tableCache`(Identifier→Table)、`partitionCache`(Identifier→List)、`manifestCache`(Path→manifest segments)。**这是 iceberg SPI 路径所缺、而 paimon 天然具备的 metastore-client 级缓存层**,是本审计结论的决定性因素。
+
+### 二、当前缓存(Caches)
+
+| 缓存 | 作用域 | Key | 值 | TTL / 失效 | file:line |
+|---|---|---|---|---|---|
+| `PaimonLatestSnapshotCache` | cross-query | `Identifier(db,table)` | latest snapshot id (long) | `meta.cache.paimon.table.ttl-second` 默认 24h;`<=0` 关闭(no-cache catalog);Caffeine expireAfterAccess + maxSize 1000;REFRESH TABLE/DB/CATALOG 失效 | `PaimonLatestSnapshotCache.java:48`(字段 `PaimonConnector.java:124`) |
+| `PaimonSchemaAtMemo` | cross-query | `(db,table,sysTable,branch,schemaId)` | `PaimonSchemaSnapshot`(fields+partKeys+pkKeys) | 无 TTL,bounded 10000 clear-on-overflow;值不可变(schemaId 内容 write-once);REFRESH 失效 | `PaimonSchemaAtMemo.java:49`(元数据 time-travel `PaimonConnectorMetadata.java:299` 与 scan schema-evolution dict `PaimonScanPlanProvider.java:1567` 共享) |
+| `partitionViewCache`(`ConnectorPartitionViewCache`,共享工具箱) | cross-query | `PartitionViewCacheKey(db,table,snapshotId,schemaId=-1)` | 已构建 `List` | `meta.cache.paimon.partition_view.*` 默认 ON/24h/1000;snapshotId 复用 latestSnapshotCache 追踪"current";REFRESH 失效 | `PaimonConnectorMetadata.java:1012-1052`(字段 `PaimonConnector.java:143`) |
+| Transient Table 胖句柄 | per-scan / per-op | `PaimonTableHandle` 实例身份 | 已加载 Table 的 transient 引用 | 句柄对象生命周期;FE→BE 序列化后丢失并 reload;不跨不同 fe-core op(每次 `resolveConnectorTableHandle` 重建),但在单个 SPI 方法内 / 单个 scan node 内(`PluginDrivenScanNode` 缓存 currentHandle+resolveScanProvider)共享 | `PaimonTableHandle.java:89`(set `PaimonConnectorMetadata.java:221`,read `PaimonTableResolver.java:65`) |
+| paimon SDK `CachingCatalog`(tableCache/partitionCache/manifestCache) | metastore-client | `Identifier` / `Path` | Table / List / manifest segments | paimon `cache.*` SDK 默认;`CACHE_ENABLED` 默认 true 故生产 catalog 被包裹 | 外部 SDK `org.apache.paimon.catalog.CachingCatalog`(包裹于 `PaimonConnector.java:454`) |
+| `DRIVER_CLASS_LOADER_CACHE` / `REGISTERED_DRIVER_KEYS` | none | driver URL / url#class | ClassLoader / 已注册标记 | 进程级 JDBC 驱动去重,非元数据缓存 | `PaimonConnector.java:89-90` |
+
+### 三、Funnel 参与(Funnel participation)
+
+已完全接入 Layer-2 per-statement funnel:`PluginDrivenExternalTable.resolveConnectorTableHandle` 经 `PluginDrivenMetadata.get`(每 (statement,catalog) memoize 一个 metadata);`PluginDrivenScanNode` 另在 `cachedMetadata` 缓存并按 currentHandle 身份 memoize `resolveScanProvider()`(`:182,189,245-259`)。故一个 scan node 内 `planScan` 与 `getScanNodeProperties` 共享同一 provider 与同一 currentHandle(携带 transient Table),`resolveScanTable` 命中 transient。`schemaAtMemo` 由长生命周期 `PaimonConnector` 同时注入 `getMetadata`(`:247`)与 `getScanPlanProvider`(`:311`),即每 catalog 一份。
+
+`metadataMemoizesLoadedTable = partial`:`getMetadata` 每语句返回**全新** `PaimonConnectorMetadata`(`:244-248`),每个 fe-core op 重新 `getTableHandle → catalogOps.getTable`(`:211`)构造新句柄。这与 iceberg 审计指出的 re-getTableHandle 模式相同,**但对 paimon 不构成 remote-IO 放大**:paimon SDK `CachingCatalog.tableCache` 使每次 getTable 成为内存命中,transient 胖句柄在单 op 内折叠重载。因此 funnel 的"每语句一 metadata"本身并不折叠 paimon 的表加载,折叠由 paimon SDK 缓存 + transient 句柄完成。iceberg PERF-07 式"每语句加载一次"表 memo 对 paimon 仅省下内存 tableCache 查找与 `setPaimonTable` 抖动 —— 可忽略。
+
+### 四、热点路径审计(Hot-path findings)
+
+| id | 区域 | 问题 | 倍率 | 成本 | 严重度 | 已memo? | file:line |
+|---|---|---|---|---|---|---|---|
+| PA-1 | partitions | `listPartitionNames`(`:988`)/`listPartitionValues`(`:1057`)直接调 `collectPartitions()`,**绕过** partitionViewCache;仅 `listPartitions`(`:1019-1022`)走派生缓存。SHOW PARTITIONS 与 partition_values() TVF 重跑渲染(变体 C) | 每次调用 1x(非循环);原始 remote listPartitions 已被 paimon SDK partitionCache 挡下,残留仅 CPU 重渲染 | cpu | **P2** | 否 | `PaimonConnectorMetadata.java:988` |
+| PA-2 | query-plan | 每 fe-core op 经 getTableHandle→getTable(re-getTableHandle 模式),未由 metadata 折叠 | ~4-7 次/语句,首次后均为 CachingCatalog 内存命中(非 remote) | remote-io | none | 是(SDK+transient) | `PaimonConnectorMetadata.java:211` |
+| PA-3 | stats-rowcount | `getTableStatistics → rowCount` 用 `newReadBuilder().newScan().plan().splits()` 全 manifest 计划求行数,连接器无 memo | 每语句 1 次;fe-core `RowCountCache`(`PluginDrivenExternalTable.java:907`)跨查询缓存,manifest 读被 paimon manifestCache 挡 | remote-io | none | 是(fe-core 层) | `PaimonConnectorMetadata.java:1205` |
+| PA-4 | schema | `getTableSchema → latestSchema` 为 `schemaManager().latest()` 活读(刻意不用冻结 rowType(),以捕获外部 ALTER) | 每 schema-cache miss 1 次;fe-core 通用 schema 缓存(经 `schemaCacheTtlSecondOverride`)跨查询挡下 | remote-io | none | 是(fe-core schema cache) | `PaimonConnectorMetadata.java:250` |
+| PA-5 | split-enum | `planScanInternal → planSplits(scan)` 读快照/manifest 枚举 split(即查询本身的数据规划,不可缓存);周边不变量已提升一次/scan | 每 scan 1 次;跨查询 manifest 读被 paimon manifestCache 挡 | remote-io | none | 是(paimon manifestCache) | `PaimonScanPlanProvider.java:461` |
+
+per-scan/per-file 不变量已充分提升:vended token(`:559-560`)、FE 权重分母(`:565`)、native 目标 split size 惰性一次(`:593,624-626`)、partitionValues 每 DataSplit 一次并复用于全部字节切片子 split(`:605,634`)。即 iceberg PERF-06/PERF-11 的等价已就位。
+
+### 五、授权 / 一致性(Authz / consistency)
+
+`perUserCredential=false`,`sessionUserRelevant=false`。paimon **无** 按 Doris 用户的会话凭证模型:catalog 在创建时一次性认证(Kerberos 插件 UGI / HMS principal / 静态或 JDBC 凭证);REST vended 凭证在 scan 时从**表级** `RESTTokenFileIO` 提取(`PaimonScanPlanProvider.extractVendedToken:916`),**不按用户键控**。故 name-only 缓存无 "list≠load" 泄漏风险。已证实:fe-connector-paimon 与 fe-connector-metastore-paimon 内无 `SUPPORTS_USER_SESSION`/`isUserSessionEnabled`;`PaimonRestMetaStoreProvider` 无任何 per-user/delegation 逻辑;`tools/check-authz-cache-sharding.sh` 仅针对 `IcebergConnector`。三个连接器缓存无条件构造(从不置空)对 paimon 是正确的(`PaimonConnector.java:138-142` 记录了理由)。写路径 read+write 共享一 txn 的一致性(iceberg `CatalogStatementTransaction`)对 paimon **N/A**(写未迁移)。读侧 MVCC 一致性(查询内稳定快照 pin)由 `PaimonLatestSnapshotCache → beginQuerySnapshot → applySnapshot → scan.snapshot-id` 提供。
+
+### 六、框架部件需求(Framework pieces)
+
+- **already-has(6)**:per-statement funnel 路由;partition-cache(partitionViewCache+SDK partitionCache);manifest-cache(SDK manifestCache);per-scan-hoist;per-file-split-memo;shared-fe-connector-cache 采用(partitionViewCache/latestSnapshotCache 用共享工具箱,仅 schemaAtMemo 用裸 CHM,可选统一)。
+- **no(5)**:cross-query-table-cache(与 SDK tableCache 重复,且 latestSnapshotCache 已提供稳定快照语义);format-cache(格式来自 `table.options()` 内存读,无 whole-table planFiles 回退);comment-cache(comment 来自 coreOptions,无 N-per-query remote getComment);authz-session-user-isolation(无 per-user 会话模型);write-txn-coholder(写未迁移)。
+
+### 七、结论与建议
+
+paimon **已充分缓存,无需套用 iceberg 热点/框架移植**。根因:(1) 生产 catalog 由 paimon SDK `CachingCatalog` 包裹,提供 iceberg SPI 路径缺失的 table/partition/manifest 内存缓存;(2) 连接器已恢复三个 legacy 语义缓存 + transient 胖句柄,并完成 per-scan/per-file 不变量提升;(3) funnel 已路由其 ConnectorMetadata。
+
+优先建议:
+1. **不要**对 paimon 移植 iceberg 式 table/format/comment/manifest 缓存或 session=user 隔离(均结构性不适用或与 SDK 缓存重复)。
+2. (可选,P2)将 `listPartitionNames`/`listPartitionValues` 也路由经 `partitionViewCache`(修 PA-1),消除派生视图重渲染的兄弟路径旁路——价值偏低,因原始 remote 读已被 paimon partitionCache 挡下。
+3. (可选,整洁性)将 `PaimonSchemaAtMemo` 迁到共享 `MetaCacheEntry` 工具箱,统一 TTL/metrics —— 纯粹一致性,非正确性或性能缺口。
+
+---
+
+## 复核结论 (adversarial verify)
+
+**Verdict: CONFIRMED** · 确认发现 IDs: PA-1, PA-2, PA-3, PA-4, PA-5
+
+| 原始声明 | 问题 | 更正 | 证据 |
+|---|---|---|---|
+| paimon SDK CachingCatalog ... CACHE_ENABLED default true so the live catalog IS wrapped (CatalogFactory.createCatalog -> CachingCatalog.tryToCreate); paimon cache.* options (cache.expiration-interval etc.) | Immaterial label imprecision: the actual paimon option key string is 'cache-enabled' (hyphen), and the connector calls the paimon (not Doris) org.apache.paimon.catalog.CatalogFactory at PaimonConnector.java:454. The substantive claim is fully verified in bytecode. | Verified: paimon-core-1.3.1 CatalogFactory.createCatalog(ctx,cl) invokestatic CachingCatalog.tryToCreate, which gates on CatalogOptions.CACHE_ENABLED and areturns the raw catalog when false; the CACHE_ENABLED field is built as booleanType().defaultValue(Boolean.valueOf(true)) (iconst_1 at offset 202), description 'Controls whether the catalog will cache databases, tables, manifests and partitions.' Default IS true; wrap covers db/table/manifest/partition. No change to any conclusion. | |
+
+> 复核备注:Every material claim holds against HEAD (aaab68ef474) code and the paimon 1.3.1 SDK bytecode. No hallucinated file:line, no invented cache class, no overstated multiplier, no false-positive finding, no missed severe finding.
+
+MIGRATION: paimon in SPI_READY_TYPES (CatalogFactory.java:57); LIVE via PaimonConnectorProvider->PaimonConnector->PaimonConnectorMetadata/PaimonScanPlanProvider; no write capability (getCapabilities PaimonConnector.java:321-341 declares only MVCC/PARTITION_STATS/COLUMN_AUTO_ANALYZE/SHOW_CREATE_DDL). CONFIRMED.
+
+CACHES (all 6 exist, scope/key correct):
+- PaimonLatestSnapshotCache.java:48 MetaCacheEntry, shared CacheSpec toolkit, ttl=meta.cache.paimon.table.ttl-second, maxSize 1000, <=0 disables. Built PaimonConnector.java:154-155, field :124. Invalidations :256/278/285. CONFIRMED.
+- PaimonSchemaAtMemo.java:49 raw ConcurrentHashMap (:54) clear-on-overflow (:81-82) maxSize 10000, key MemoKey(db,table,sysTableName,branchName,schemaId) (:129-142), immutable value. Injected into getMetadata (:247) AND getScanPlanProvider (:311). CONFIRMED including the 'only schemaAtMemo uses a raw CHM' framework-piece note.
+- partitionViewCache: ConnectorPartitionViewCache + PartitionViewCacheKey exist in fe-connector-cache; routed in PaimonConnectorMetadata.listPartitions :1012-1022, key (db,table,snapshotId,-1) via latestSnapshotCache :1046-1052; field PaimonConnector.java:143 built :158; invalidations :263/280/287. CONFIRMED.
+- transient fat-handle: PaimonTableHandle.java:89 (transient Table), set PaimonConnectorMetadata.java:221, read PaimonTableResolver.java:65. CONFIRMED.
+- paimon SDK CachingCatalog: DECISIVELY verified in bytecode (see corrections) - wraps db/table/manifest/partition, default on. CONFIRMED - this is the load-bearing fact behind the whole 'do not port iceberg caches' verdict and it holds.
+- DRIVER_CLASS_LOADER_CACHE/REGISTERED_DRIVER_KEYS PaimonConnector.java:89-90, process-wide, not a metadata cache. CONFIRMED.
+
+FUNNEL (partial memoization): PluginDrivenMetadata.get memoizes one ConnectorMetadata per (statement,catalog) via scope.getOrCreateMetadata (:70) with per-statement identity guard (:64-69). getMetadata returns a FRESH PaimonConnectorMetadata per statement (:244-248). resolveConnectorTableHandle (:116-119) re-calls getTableHandle->catalogOps.getTable (:211) on EVERY fe-core op with NO caching (~15 call sites in PluginDrivenExternalTable each do PluginDrivenMetadata.get + resolveConnectorTableHandle). So the metadata does NOT memoize the loaded Table; collapse comes from SDK tableCache + transient handle. PluginDrivenScanNode: cachedMetadata (:189/204-207) + resolveScanProvider memo keyed on currentHandle identity (:245-259). All CONFIRMED.
+
+HOTPATH FINDINGS:
+- PA-1 CONFIRMED as real Variant-C cache bypass: listPartitionNames (PaimonConnectorMetadata.java:988) and listPartitionValues (:1057) call collectPartitions() directly; only listPartitions (:1012-1022) routes through partitionViewCache. This is the ONLY actionable item. Its P2 rating is arguably slightly generous (CPU-only re-render since raw remote read is blunted by SDK partitionCache) but the audit itself flags it 'low-value', so self-consistent.
+- PA-2..PA-5 CONFIRMED as accurately characterized non-findings (severity none / memoizedAlready true): heavy ops are all REAL (getTable :211; rowCount = full newReadBuilder().newScan().plan().splits() sum PaimonCatalogOps.java:368-376; latestSchema = live schemaManager().latest() :345-355, intentionally not off frozen rowType; planSplits :461) but each is genuinely blunted by SDK tableCache/manifestCache + transient handle + fe-core RowCountCache (PluginDrivenExternalTable.java:907) / generic schema cache. Per-scan/per-file loop-invariants are all hoisted as claimed (vendedToken :559-560, weightDenominator :565, targetSplitSize lazy-once :624-626, getPartitionInfoMap once-per-DataSplit :605 reused across sub-splits :634). No hidden per-split remote loadTable in any loop.
+
+AUTHZ: no SUPPORTS_USER_SESSION/isUserSessionEnabled anywhere in fe-connector-paimon or fe-connector-metastore-paimon (only comments explaining paimon has no such model); tools/check-authz-cache-sharding.sh TARGET_REL is IcebergConnector.java only (:57); REST vended token extracted table-level via extractVendedToken (PaimonScanPlanProvider.java:916), not Doris-user-keyed. Name-only caches safe. CONFIRMED.
+
+Overall verdict (do NOT port iceberg-style caching; optionally take PA-1; optionally migrate PaimonSchemaAtMemo to MetaCacheEntry) stands unchanged.
diff --git a/plan-doc/connector-cache-unification/connectors/trino.md b/plan-doc/connector-cache-unification/connectors/trino.md
new file mode 100644
index 00000000000000..8466a0cf8d2b27
--- /dev/null
+++ b/plan-doc/connector-cache-unification/connectors/trino.md
@@ -0,0 +1,100 @@
+## 连接器附录:trino-connector(`fe/fe-connector/fe-connector-trino`)
+
+### 定位:唯一的 LIVE pass-through bridge
+
+trino-connector 在 8 个 SPI 连接器里独一无二:它不是自己实现元数据,而是**内嵌一个真正的 Trino Connector SPI**,把 Doris 的 metadata/scan 请求转译后委托给内嵌 Trino 连接器。链路:
+
+- `TrinoConnectorProvider.getType()` = `"trino-connector"`,`create()` → `TrinoDorisConnector`(`TrinoConnectorProvider.java:35,40`)。
+- `TrinoDorisConnector` 双检锁 `doInitialize()` 通过 `TrinoBootstrap` 加载 Trino 插件、每个 Doris catalog 造**一个** live `io.trino.spi.connector.Connector` 并 volatile 持有(`TrinoDorisConnector.java:53-57,133-188`)。
+- `getMetadata()` 每次 new 一个 `TrinoConnectorDorisMetadata`(funnel 会把它 per-statement memo 掉),`getScanPlanProvider()` new `TrinoScanPlanProvider`(`TrinoDorisConnector.java:64-75`)。
+
+**迁移状态**:`SPI_READY_TYPES` 含 `"trino-connector"`(`CatalogFactory.java:57`),经 `CatalogFactory.java:110-118` 走 SPI/`PluginDrivenExternalCatalog` 路径。**live**,非 sibling-only / dormant / legacy。**只读**:`TrinoConnectorDorisMetadata` 对 create/drop/rename/beginWrite/executeStmt 的 override 数 = 0。
+
+### Caches 一览
+
+| Cache | 作用域 | Key | TTL / 失效 | 位置 |
+|---|---|---|---|---|
+| `TrinoBootstrap.instance`(typeRegistry/handleResolver/pluginManager 插件基座) | 进程级(cross-query) | pluginDir(全 FE 唯一) | 进程生命周期,不失效 | `TrinoBootstrap.java:100,141-156` |
+| `TrinoDorisConnector.trinoConnector`(每 catalog 内嵌的 live Trino Connector) | cross-query | Doris catalog | catalog 生命周期;`close()`→`shutdown()` 失效 | `TrinoDorisConnector.java:53-57,133-141,95-102` |
+| **内嵌 Trino 连接器自身的元数据缓存**(CachingHiveMetastore / iceberg metadata cache / CachingJdbcClient)= Trino 表真正的跨查询元数据缓存 | metastore-client | Trino 自持 key;由连接器配置启用+定大小(如 `hive.metastore-cache-ttl`) | Trino 配置驱动(多数 Trino 连接器默认关闭) | 位于 `trinoConnector` 内部,本模块无源码;每次 `trinoConnector.getMetadata(...).getTableHandle(...)` read-through |
+| `TrinoServicesProvider.catalogs` | cross-query | `CatalogHandle` | catalog 生命周期 | `TrinoServicesProvider.java:63` |
+| `TrinoPluginManager.connectorFactories` | cross-query | `ConnectorName` | 进程生命周期 | `TrinoPluginManager.java:64` |
+| **Doris 连接器层的 table/handle/schema 缓存** | **none** | 不存在 —— `TrinoConnectorDorisMetadata` 零 memo;pom 无 `fe-connector-cache` 依赖 | n/a | `TrinoConnectorDorisMetadata.java`(整类) |
+| (fe-core 通用,非 trino 专属)`ExternalSchemaCache` + `RowCountCache` | cross-query | catalog.db.table 名;全连接器共享 | `external_cache_expire_time`,REFRESH 失效 | `PluginDrivenExternalTable.java:430,1121` |
+
+一句话:**Doris 连接器层没有任何跨查询/每语句元数据缓存**;Trino 表的元数据缓存全部在内嵌 Trino 连接器内部,fe-core 通用的 schema/rowcount 缓存兜底跨查询重复。
+
+### Funnel 参与情况
+
+**已进 funnel**:`PluginDrivenScanNode`/`PluginDrivenExternalTable` 所有元数据获取均经 `PluginDrivenMetadata.get(session, connector)`(`PluginDrivenScanNode.java:206,222`;`PluginDrivenExternalTable.java:449,1126`),每语句每 catalog 只 memo 一个 `TrinoConnectorDorisMetadata`,`tools/check-fecore-metadata-funnel.sh` 全库把关。
+
+**关键点:该 wrapper 内部零 memo**。`listDatabaseNames/listTableNames/getTableHandle/getTableSchema/applyFilter/applyProjection` 每个方法都 `beginTransaction(READ_UNCOMMITTED)` 起**一笔全新 Trino 事务** + `trinoConnector.getMetadata(...)` 再 commit(`TrinoConnectorDorisMetadata.java:101,121,153,195,273,338`);`TrinoScanPlanProvider.planScan` 再起一笔(`TrinoScanPlanProvider.java:111-113`)。`getTableHandle` 还会 eager 拉全部 column handle + 每列 `getColumnMetadata`(`:166-176`)。scope 上不 stash 任何已解析的 `TrinoTableHandle`/Trino `Table`。
+
+所以 funnel 给出的是"每语句一个 Doris wrapper",**并不折叠 wrapper 内部的重复加载**。但残余 multiplier 低:(a) fe-core 的 `ExternalSchemaCache`/`RowCountCache` 跨查询兜底 `getTableHandle`/`getTableSchema`;(b) 每次 `getTableHandle` 又 read-through 内嵌 Trino 连接器自身的长生命周期 metastore 缓存。`metadataMemoizesLoadedTable = no`。
+
+### Hot-path 审计(应用 problem class)
+
+Trino wrapper 只 override 了 list/handle/schema/pushdown 这一小撮方法;`listPartitions`、`getTableStatistics`、`estimateDataSizeByListingFiles`、`getMvccPartitionView`、`getTableFreshness`、`getSyntheticScanPredicates` 以及整条写路径**全走接口默认值(空 / -1 / handle 原样返回)**。因此 iceberg 的 PERF-02(PARTITIONS 重扫)、PERF-03(format 回退)、PERF-04(manifest)、PERF-07/R6(写)在这里**没有对应热点**。
+
+| ID | 区域 | 问题 | multiplier | cost | 严重度 | 已 memo? | 位置 |
+|---|---|---|---|---|---|---|---|
+| TRINO-H1 | query-plan | `getTableHandle`(唯一近似 remote 的元数据 op)在冷语句里从 3 个 fe-core 站点被调:initSchema(`PluginDrivenExternalTable.java:463`)、fetchRowCount(`:1128`)、scan create(`PluginDrivenScanNode.java:228`);wrapper 内不 memo 已解析 handle | 暖缓存 ~1x/SELECT;冷 ~3x —— 但 schema 走 ExternalSchemaCache、rowcount 走 RowCountCache,且各次 read-through Trino 自身缓存 | mixed | P2 | 是(fe-core 层) | `PluginDrivenScanNode.java:228` |
+| TRINO-H2 | schema | 一次冷 schema 加载里 `getTableHandle` 已按列建好 `columnMetadataMap`(`:170-176`),`getTableSchema` 却弃之不用、每列重新 `getColumnMetadata`(`:207-209`)= 2N 次 + 第二笔 Trino 事务 | 2N getColumnMetadata / 冷 schema miss(非每查询) | cpu | P2 | 否 | `TrinoConnectorDorisMetadata.java:207` |
+| TRINO-H3 | predicate | applyFilter 每次 scan 跑两遍:fe-core `convertPredicate`(`PluginDrivenScanNode.java:824`,自带一笔 Trino 事务)+ `planScan` 内再一遍(`TrinoScanPlanProvider.java:126`,另一笔事务);projection 同理 | 2x applyFilter + 2x applyProjection / scan | cpu | P2 | 否 | `TrinoScanPlanProvider.java:126` |
+| TRINO-H4 | split-enum | split 枚举全委托 Trino `ConnectorSplitManager`+`BufferingSplitSource`;per-split 循环只序列化各自 split JSON + host。scan 不变字段(tableHandle/txn/columnHandles/columnMetadata/options JSON)已在循环前**一次性**预序列化 | 共享字段 1x/scan(已 hoist) | cpu | none | 是 | `TrinoScanPlanProvider.java:221-227` |
+| TRINO-H5 | partitions/stats/write | 相关方法均未 override → 默认空/‑1/原样;`getNameToPartitionItems` 见空分区,`fetchRowCount` 返回 UNKNOWN;无写路径 | 0(无此调用) | cpu | none | 是 | `TrinoConnectorDorisMetadata.java:64` |
+
+**无 P0/P1**。不存在"重 remote op × 高 multiplier × 无 memo"的 A/B/C/D 变体:唯一的 remote-ish op(`getTableHandle`)既被 fe-core 跨查询缓存兜住,又被内嵌 Trino 连接器自身缓存兜住;真正的 per-split 循环只做不可避免的 split 序列化。
+
+### Authz / 一致性
+
+`perUserCredential = false`,`sessionUserRelevant = false`。`TrinoBootstrap` 在 CREATE CATALOG 时把**单一静态身份** `Identity.ofUser("user")` 烤进每 catalog 的 Trino Session(`TrinoBootstrap.java:264-265`),对所有 Doris 用户复用。无 REST OIDC、无 kerberos doAs、无 per-identity metastore、无 vended per-user 凭证;内嵌 Trino 连接器用 catalog 属性里的**静态**凭证(metastore auth / 对象存储 key)访问底层。访问控制由 Doris 自身权限系统负责。
+
+因此:**name-only 缓存不会泄漏 per-user 授权**(根本没有 per-user 维度可分片),iceberg 的 `session=user` "list ≠ load" 元数据泄漏、Layer-3 隔离与 `shouldBypassSchemaCache` 在此**N/A**。`PluginDrivenMetadata` 的 builder-identity 断言(`PluginDrivenMetadata.java:63-69`)仍跑但恒真。写一致性(读写共享一 metadata/txn)也 N/A —— 只读连接器,无写事务。
+
+### 框架各件是否需要
+
+- **per-statement-funnel-memoization** = partial:已进 funnel;wrapper 内部再 memo 已解析 `TrinoTableHandle` 可省掉每 SELECT 多出的 Trino 事务,但收益小(schema/rowcount 缓存 + Trino 自身缓存已兜底)。低优先。
+- **cross-query-table-cache** = **no**:会与内嵌 Trino 连接器自身缓存**双重缓存**,并把外部 ALTER 后的 schema 冻住,重新引入 Trino 已解决的 stale-metadata bug;且 `TrinoTableHandle` 是事务派生 + transient/不可序列化。**禁止加**。
+- **partition-cache / format-cache / comment-cache / manifest-or-file-list-cache** = no:分区(默认空)、file-format(在 Trino/BE 侧)、comment(默认 ""、随缓存 schema)、file-listing(默认 -1、split 交给 Trino)均无 FE 侧热点。
+- **per-scan-hoist** = already-has(`TrinoScanPlanProvider.java:221-227` 已把 scan 不变字段循环前一次序列化)。
+- **per-file-split-memo** = no:无跨 split 的 per-file 不变量可 memo。
+- **authz-session-user-isolation** = no:静态单身份,无可分片缓存、无泄漏面。
+- **shared-fe-connector-cache-adoption** = no:连接器层无可安全缓存之物,采纳只会制造双缓存正确性隐患。
+- **write-txn-coholder** = no:只读,无写事务。
+
+### 结论与建议
+
+**不要**把 iceberg 热点缓存框架套到 trino-connector。它是委托型 bridge,元数据缓存 / 分区&stats / split 枚举 / 缓存失效全部由内嵌 Trino 连接器负责;跨查询重复由 fe-core 通用 `ExternalSchemaCache`/`RowCountCache` + Trino 自身缓存两层兜底。Layer-1 连接器缓存与 Layer-3 授权隔离在此**反而有害**(双缓存 + stale 风险;无 per-user 维度);Layer-2 funnel 已接好且被 gate 强制,残余 multiplier 低。
+
+优先级:**维持现状**(委托正确)。若某个热 catalog 的 BI profile 真的暴露出来,再排期三个 P2 纯 CPU 清理:(1)per-statement memo 已解析 `TrinoTableHandle` 以消掉多余 Trino 事务;(2)冷 schema 加载去掉 `getTableHandle`/`getTableSchema` 的 2N `getColumnMetadata` 重复;(3)消除 `convertPredicate` 与 `planScan` 之间的双重 applyFilter/applyProjection。
+
+---
+
+## 复核结论 (adversarial verify)
+
+**Verdict: CONFIRMED** · 确认发现 IDs: TRINO-H1, TRINO-H2, TRINO-H3, TRINO-H4, TRINO-H5
+
+| 原始声明 | 问题 | 更正 | 证据 |
+|---|---|---|---|
+| Cache #4 'TrinoServicesProvider.catalogs' keyedBy: CatalogHandle | The map is actually keyed by the catalog-name String, not a CatalogHandle object; lookups derive the name from the handle. | TrinoServicesProvider.java:63 declares `ConcurrentMap]