.partition_view.*). Keyed by
PartitionViewCacheKey (db, table, snapshotId, schemaId).
Framework only, no consumers yet -- iceberg/paimon/hive/maxcompute
wiring is later subsystems. No fe-core changes.
---
.../cache/ConnectorPartitionViewCache.java | 110 +++++++
.../cache/PartitionViewCacheKey.java | 100 +++++++
.../ConnectorPartitionViewCacheTest.java | 278 ++++++++++++++++++
3 files changed, 488 insertions(+)
create mode 100644 fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorPartitionViewCache.java
create mode 100644 fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/PartitionViewCacheKey.java
create mode 100644 fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorPartitionViewCacheTest.java
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/ConnectorPartitionViewCache.java
new file mode 100644
index 00000000000000..cd7f03be986ee7
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/ConnectorPartitionViewCache.java
@@ -0,0 +1,110 @@
+// 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.cache;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ForkJoinPool;
+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).
+ *
+ * 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
+ * #get} then calls the loader on every call, matching {@code IcebergPartitionCache}'s disabled-cache bypass.
+ *
+ * Concurrency: mirrors {@code IcebergPartitionCache} / {@code MaxComputePartitionCache} exactly — the
+ * entry is contextual-only (no built-in loader; the caller supplies one per {@link #get} call) with manual miss
+ * 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 {
+
+ /** {@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;
+
+ /**
+ * @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).
+ */
+ public ConnectorPartitionViewCache(String engine, Map props) {
+ Objects.requireNonNull(engine, "engine can not be null");
+ Map properties = props == null ? Collections.emptyMap() : props;
+ CacheSpec spec = CacheSpec.fromProperties(properties, engine, ENTRY_PARTITION_VIEW,
+ 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,
+ ForkJoinPool.commonPool(), false, true, 0L, true);
+ }
+
+ /** Caching is on only when the resolved {@link CacheSpec} is effectively enabled (see {@link #entry}'s spec). */
+ public boolean isEnabled() {
+ return entry.stats().isEffectiveEnabled();
+ }
+
+ /**
+ * Returns the cached value for {@code key} if present, else runs {@code loader}, caches and returns it.
+ * 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) {
+ Objects.requireNonNull(loader, "loader can not be null");
+ return entry.get(key, ignored -> loader.get());
+ }
+
+ /** Evicts every cached snapshot/schema entry of one (db, table). Backs REFRESH TABLE / table-level invalidation. */
+ public void invalidateTable(String db, String table) {
+ entry.invalidateIf(key -> key.matches(db, table));
+ }
+
+ /** Evicts every cached entry of one db (all its tables). Backs REFRESH DATABASE / db-level invalidation. */
+ public void invalidateDb(String db) {
+ entry.invalidateIf(key -> key.matchesDb(db));
+ }
+
+ /** Evicts every cached entry. Backs REFRESH CATALOG. */
+ public void invalidateAll() {
+ entry.invalidateAll();
+ }
+
+ /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */
+ long size() {
+ int[] count = {0};
+ entry.forEach((key, value) -> count[0]++);
+ return count[0];
+ }
+}
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/PartitionViewCacheKey.java
new file mode 100644
index 00000000000000..f00d39465475a6
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/PartitionViewCacheKey.java
@@ -0,0 +1,100 @@
+// 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.cache;
+
+import java.util.Objects;
+
+/**
+ * Immutable cache key for {@link ConnectorPartitionViewCache}: {@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
+ * into the key is what makes the cache "always correct" — a new snapshot/schema yields a new key, never a stale
+ * 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
+ * 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 {
+ 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) {
+ this.db = db;
+ this.table = table;
+ this.snapshotId = snapshotId;
+ this.schemaId = schemaId;
+ }
+
+ public String getDb() {
+ return db;
+ }
+
+ public String getTable() {
+ return table;
+ }
+
+ public long getSnapshotId() {
+ return snapshotId;
+ }
+
+ public long getSchemaId() {
+ return schemaId;
+ }
+
+ /** Whether this key belongs to the given (db, table), regardless of snapshotId/schemaId. */
+ public boolean matches(String db, String table) {
+ return Objects.equals(this.db, db) && Objects.equals(this.table, table);
+ }
+
+ /** Whether this key belongs to the given db, regardless of table/snapshotId/schemaId. */
+ public boolean matchesDb(String db) {
+ return Objects.equals(this.db, db);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof PartitionViewCacheKey)) {
+ return false;
+ }
+ PartitionViewCacheKey that = (PartitionViewCacheKey) o;
+ return snapshotId == that.snapshotId
+ && schemaId == that.schemaId
+ && Objects.equals(db, that.db)
+ && Objects.equals(table, that.table);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(db, table, snapshotId, schemaId);
+ }
+
+ @Override
+ public String toString() {
+ return "PartitionViewCacheKey{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/ConnectorPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorPartitionViewCacheTest.java
new file mode 100644
index 00000000000000..95a80e5d6f164d
--- /dev/null
+++ b/fe/fe-connector/fe-connector-cache/src/test/java/org/apache/doris/connector/cache/ConnectorPartitionViewCacheTest.java
@@ -0,0 +1,278 @@
+// 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.cache;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Unit tests for {@link ConnectorPartitionViewCache} — 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})
+ * since this module must not depend on any engine-specific type. Covers: miss-then-hit, distinct-snapshot keying,
+ * per-table / per-db / whole-cache invalidation, and the disabled-cache bypass (both {@code enable=false} and
+ * {@code ttl-second=0}).
+ */
+public class ConnectorPartitionViewCacheTest {
+
+ 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 ConnectorPartitionViewCache newCache() {
+ return new ConnectorPartitionViewCache<>(ENGINE, new HashMap<>());
+ }
+
+ @Test
+ public void missThenHitLoaderRunsOnceWithinTtl() {
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorPartitionViewCache cache = newCache();
+
+ String first = cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "view-v1";
+ });
+ // Second read of the SAME key must be served from cache, NOT re-invoke the loader -- this is the whole
+ // point of memoizing the expensive partition enumeration across queries. MUTATION: always calling the
+ // loader -> second != first / loads == 2 -> red.
+ String second = cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "view-v2";
+ });
+
+ Assertions.assertEquals("view-v1", first);
+ Assertions.assertEquals("view-v1", second, "within TTL the cached value must be served, not a fresh load");
+ Assertions.assertEquals(1, loads.get(), "the loader must run exactly once for the same key");
+ Assertions.assertTrue(cache.isEnabled());
+ }
+
+ @Test
+ public void differentSnapshotIdIsADistinctEntry() {
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorPartitionViewCache cache = newCache();
+
+ cache.get(key("db", "t", 1L, 1L), () -> {
+ loads.incrementAndGet();
+ return "snap-1";
+ });
+ // A new commit yields a new snapshotId -> a distinct key -> the loader must run again. MUTATION: keying
+ // by (db, table) only (ignoring snapshotId/schemaId) -> loads stays 1 / serves the stale snap-1 value.
+ String second = cache.get(key("db", "t", 2L, 1L), () -> {
+ loads.incrementAndGet();
+ return "snap-2";
+ });
+
+ Assertions.assertEquals("snap-2", second);
+ Assertions.assertEquals(2, loads.get(), "distinct snapshotId must trigger a distinct load");
+ }
+
+ @Test
+ public void differentSchemaIdIsADistinctEntry() {
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorPartitionViewCache cache = newCache();
+
+ cache.get(key("db", "t", 1L, 1L), () -> {
+ loads.incrementAndGet();
+ return "schema-1";
+ });
+ // schemaId is part of the key too (paimon evolves schema independently of snapshot). MUTATION: dropping
+ // schemaId from equals/hashCode -> this second call would incorrectly hit -> loads stays 1.
+ String second = cache.get(key("db", "t", 1L, 2L), () -> {
+ loads.incrementAndGet();
+ return "schema-2";
+ });
+
+ Assertions.assertEquals("schema-2", second);
+ Assertions.assertEquals(2, loads.get(), "distinct schemaId must trigger a distinct load");
+ }
+
+ @Test
+ public void invalidateTableEvictsAllSnapshotsOfThatTableOnly() {
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorPartitionViewCache cache = newCache();
+
+ cache.get(key("db", "t", 1L, 1L), () -> "v1");
+ cache.get(key("db", "t", 2L, 1L), () -> "v2");
+ cache.get(key("db", "other", 1L, 1L), () -> "v3");
+
+ // REFRESH TABLE db.t must drop BOTH snapshot entries of db.t and leave db.other intact. MUTATION:
+ // invalidating a single (db, table, snapshotId) key instead of every snapshot of the table -> the
+ // reload below would still hit the stale "v1".
+ cache.invalidateTable("db", "t");
+
+ String reload = cache.get(key("db", "t", 1L, 1L), () -> {
+ loads.incrementAndGet();
+ return "v1-reloaded";
+ });
+ Assertions.assertEquals("v1-reloaded", reload, "invalidateTable must force a fresh load for db.t");
+ Assertions.assertEquals(1, loads.get());
+
+ // db.other must be untouched by the db.t invalidation.
+ AtomicInteger otherLoads = new AtomicInteger();
+ String other = cache.get(key("db", "other", 1L, 1L), () -> {
+ otherLoads.incrementAndGet();
+ return "v3-should-not-reload";
+ });
+ Assertions.assertEquals("v3", other, "invalidateTable(db, t) must not touch db.other");
+ Assertions.assertEquals(0, otherLoads.get());
+ }
+
+ @Test
+ public void invalidateDbClearsOnlyThatDbsTables() {
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorPartitionViewCache cache = newCache();
+
+ cache.get(key("db1", "t1", 1L, 1L), () -> "a");
+ cache.get(key("db1", "t2", 1L, 1L), () -> "b");
+ cache.get(key("db2", "t1", 1L, 1L), () -> "c");
+
+ // REFRESH DATABASE db1 must drop every table of db1 and leave db2 intact. MUTATION: invalidateDb acting
+ // like invalidateAll (or a no-op) -> either db2 also reloads, or db1 does not.
+ cache.invalidateDb("db1");
+
+ String reloadedT1 = cache.get(key("db1", "t1", 1L, 1L), () -> {
+ loads.incrementAndGet();
+ return "a-reloaded";
+ });
+ Assertions.assertEquals("a-reloaded", reloadedT1);
+ Assertions.assertEquals(1, loads.get());
+
+ AtomicInteger db2Loads = new AtomicInteger();
+ String db2Value = cache.get(key("db2", "t1", 1L, 1L), () -> {
+ db2Loads.incrementAndGet();
+ return "c-should-not-reload";
+ });
+ Assertions.assertEquals("c", db2Value, "invalidateDb(db1) must not touch db2");
+ Assertions.assertEquals(0, db2Loads.get());
+ }
+
+ @Test
+ public void invalidateAllClearsEveryEntry() {
+ AtomicInteger loads = new AtomicInteger();
+ ConnectorPartitionViewCache cache = newCache();
+
+ cache.get(key("db1", "t1", 1L, 1L), () -> "a");
+ cache.get(key("db2", "t2", 1L, 1L), () -> "b");
+
+ cache.invalidateAll();
+
+ String reloaded = cache.get(key("db1", "t1", 1L, 1L), () -> {
+ loads.incrementAndGet();
+ return "a-reloaded";
+ });
+ Assertions.assertEquals("a-reloaded", reloaded, "invalidateAll must drop every entry");
+ Assertions.assertEquals(1, loads.get());
+ }
+
+ @Test
+ 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);
+
+ String first = cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "v1";
+ });
+ // enable=false must bypass the cache entirely -- every call re-invokes the loader. MUTATION: ignoring the
+ // enable=false property -> second call would return "v1" (cached) / loads would stay 1 -> red.
+ String second = cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "v2";
+ });
+
+ Assertions.assertEquals("v1", first);
+ Assertions.assertEquals("v2", second, "enable=false must call the loader on every get");
+ Assertions.assertEquals(2, loads.get());
+ Assertions.assertFalse(cache.isEnabled());
+ }
+
+ @Test
+ 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);
+
+ cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "v1";
+ });
+ String second = cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "v2";
+ });
+
+ // ttl-second=0 is CacheSpec's explicit disable signal (distinct from -1 = "no expiration"). MUTATION:
+ // treating ttl=0 as "no expiration" instead of "disabled" -> second call would return the cached "v1".
+ Assertions.assertEquals("v2", second, "ttl-second=0 must always load live");
+ Assertions.assertEquals(2, loads.get());
+ Assertions.assertFalse(cache.isEnabled());
+ }
+
+ @Test
+ 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);
+
+ cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "v1";
+ });
+ String second = cache.get(key("db", "t", 5L, 1L), () -> {
+ loads.incrementAndGet();
+ return "v2";
+ });
+
+ Assertions.assertEquals("v2", second, "capacity=0 must always load live");
+ Assertions.assertEquals(2, loads.get());
+ Assertions.assertFalse(cache.isEnabled());
+ }
+
+ @Test
+ 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<>());
+ Assertions.assertTrue(cache.isEnabled(), "the cache must be ON by default with no override properties");
+ }
+
+ @Test
+ public void loaderExceptionPropagatesUnwrappedAndIsNotCached() {
+ ConnectorPartitionViewCache 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), () -> {
+ throw new IllegalStateException("boom");
+ }));
+
+ String recovered = cache.get(key("db", "t", 5L, 1L), () -> "recovered");
+ Assertions.assertEquals("recovered", recovered, "a failed load must not be cached");
+ }
+}
From 96d5611b5d4fa8791b147cd7337c80064786512e Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 02:55:02 +0800
Subject: [PATCH 12/19] [perf](catalog) wire cross-query derived partition-view
cache A into the iceberg connector (S3)
Layer the generic ConnectorPartitionViewCache (S2 framework) above the raw
IcebergPartitionCache so a repeated query on a partitioned iceberg table skips the
derived-view BUILD, not just the remote PARTITIONS scan. Two typed per-catalog fields
on IcebergConnector -- one for getMvccPartitionView's ConnectorMvccPartitionView and
one for listPartitions' List -- because the two SPI hooks return
structurally different derived types and neither derives from the other; the shared raw
scan underneath is already deduped by IcebergPartitionCache, so two caches never
double-scan remotely.
- Key = (db, table, snapshotId, schemaId) from the pinned handle (pure function of the
MVCC coordinate). getMvccPartitionView and the empty-filter listPartitions pruning path
are wrapped; a present filter bypasses the cache (not the pruning path).
- session=user: both fields nulled under isUserSessionEnabled(), mirroring the sibling
iceberg caches, with the check-authz-cache-sharding.sh marker on each declaration line;
a null cache computes directly every call.
- Invalidation routed from invalidateTable/invalidateDb/invalidateAll to both caches
(null-guarded).
Tests: new IcebergConnectorMetadataPartitionViewCacheTest (7) proves same-key-hit
enumerates once, distinct-snapshot re-enumerates, invalidateTable/All re-enumerate,
filter bypass, and null-cache re-enumeration; IcebergConnectorCacheTest (+3) proves the
session=user null-gate and the 4-hook routing on the real fields. Full module suite
1084/1084 green.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014ZngdXGBAXNDRe7jA42mAS
---
.../connector/iceberg/IcebergConnector.java | 59 +++-
.../iceberg/IcebergConnectorMetadata.java | 99 ++++++-
.../iceberg/IcebergConnectorCacheTest.java | 99 +++++++
...nnectorMetadataPartitionViewCacheTest.java | 260 ++++++++++++++++++
4 files changed, 503 insertions(+), 14 deletions(-)
create mode 100644 fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
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 47fb3dd5abd2ae..95677a9dd8c023 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
@@ -20,14 +20,17 @@
import org.apache.doris.connector.api.Connector;
import org.apache.doris.connector.api.ConnectorCapability;
import org.apache.doris.connector.api.ConnectorMetadata;
+import org.apache.doris.connector.api.ConnectorPartitionInfo;
import org.apache.doris.connector.api.ConnectorSession;
import org.apache.doris.connector.api.ConnectorTestResult;
import org.apache.doris.connector.api.ConnectorValidationContext;
import org.apache.doris.connector.api.DorisConnectorException;
import org.apache.doris.connector.api.handle.ConnectorTableHandle;
+import org.apache.doris.connector.api.mvcc.ConnectorMvccPartitionView;
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.metastore.HmsMetaStoreProperties;
import org.apache.doris.connector.metastore.spi.JdbcDriverSupport;
import org.apache.doris.connector.metastore.spi.MetaStoreProviders;
@@ -76,6 +79,7 @@
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -181,6 +185,19 @@ public class IcebergConnector implements Connector {
// 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
+ // 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
+ // rebuild, not just the remote scan. Two typed fields because the two SPI hooks return structurally different
+ // derived types and neither derives from the other; the shared raw PARTITIONS scan underneath is already
+ // 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
+ mvccPartitionViewCache;
+ private final ConnectorPartitionViewCache> // authz-cache-session-user-disabled
+ 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).
private final IcebergManifestCache manifestCache = new IcebergManifestCache();
@@ -254,6 +271,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
+ // 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);
+ this.listPartitionsViewCache = isUserSessionEnabled()
+ ? null
+ : new ConnectorPartitionViewCache<>("iceberg", this.properties);
}
/**
@@ -278,7 +306,8 @@ static long resolveTableCacheTtlSecond(Map properties) {
@Override
public ConnectorMetadata getMetadata(ConnectorSession session) {
return new IcebergConnectorMetadata(newCatalogBackedOps(session), properties, context,
- latestSnapshotCache, tableCache, partitionCache, commentCache);
+ latestSnapshotCache, tableCache, partitionCache, commentCache,
+ mvccPartitionViewCache, listPartitionsViewCache);
}
/**
@@ -603,6 +632,12 @@ public void invalidateTable(String dbName, String tableName) {
if (commentCache != null) {
commentCache.invalidate(TableIdentifier.of(dbName, tableName));
}
+ if (mvccPartitionViewCache != null) {
+ mvccPartitionViewCache.invalidateTable(dbName, tableName);
+ }
+ if (listPartitionsViewCache != null) {
+ listPartitionsViewCache.invalidateTable(dbName, tableName);
+ }
}
/**
@@ -633,6 +668,12 @@ public void invalidateDb(String dbName) {
if (commentCache != null) {
commentCache.invalidateDb(dbName);
}
+ if (mvccPartitionViewCache != null) {
+ mvccPartitionViewCache.invalidateDb(dbName);
+ }
+ if (listPartitionsViewCache != null) {
+ listPartitionsViewCache.invalidateDb(dbName);
+ }
}
/**
@@ -659,6 +700,12 @@ public void invalidateAll() {
if (commentCache != null) {
commentCache.invalidateAll();
}
+ if (mvccPartitionViewCache != null) {
+ mvccPartitionViewCache.invalidateAll();
+ }
+ if (listPartitionsViewCache != null) {
+ listPartitionsViewCache.invalidateAll();
+ }
manifestCache.invalidateAll();
}
@@ -713,6 +760,16 @@ IcebergCommentCache commentCacheForTest() {
return commentCache;
}
+ /** Test-only: the derived MVCC partition-view cache (PERF-06), or {@code null} for a session=user catalog. */
+ ConnectorPartitionViewCache mvccPartitionViewCacheForTest() {
+ return mvccPartitionViewCache;
+ }
+
+ /** Test-only: the derived listPartitions view cache (PERF-06), or {@code null} for a session=user catalog. */
+ ConnectorPartitionViewCache> listPartitionsViewCacheForTest() {
+ return listPartitionsViewCache;
+ }
+
@Override
public ConnectorScanPlanProvider getScanPlanProvider() {
// Mirrors PaimonConnector.getScanPlanProvider: build a fresh provider per call over the lazily-built
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 70f5d0a146e056..99a54b500ee866 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
@@ -41,6 +41,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.spi.ConnectorContext;
import org.apache.doris.thrift.THiveTable;
import org.apache.doris.thrift.TIcebergTable;
@@ -155,6 +157,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
+ // 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;
public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties,
ConnectorContext context) {
@@ -180,11 +190,28 @@ public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties,
ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache,
IcebergTableCache tableCache, IcebergPartitionCache partitionCache,
IcebergCommentCache commentCache) {
+ this(catalogOps, properties, context, latestSnapshotCache, tableCache, partitionCache, commentCache,
+ null, null);
+ }
+
+ /**
+ * Full ctor used by {@link IcebergConnector#getMetadata}, adding the PERF-06 derived partition-view caches
+ * (cache A): {@code mvccPartitionViewCache} memoizes {@link #getMvccPartitionView}'s built RANGE view and
+ * {@code listPartitionsViewCache} memoizes {@link #listPartitions}'s built partition-info list, each keyed by
+ * {@code (db, table, snapshotId, schemaId)}. Both {@code null} for a session=user catalog / the convenience
+ * ctors (no cross-query derived layer -> compute directly every call).
+ */
+ public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties,
+ ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache,
+ IcebergTableCache tableCache, IcebergPartitionCache partitionCache,
+ IcebergCommentCache commentCache,
+ ConnectorPartitionViewCache mvccPartitionViewCache,
+ ConnectorPartitionViewCache> listPartitionsViewCache) {
this.catalogOps = catalogOps;
this.properties = properties;
this.context = context;
@@ -192,6 +219,8 @@ public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map getMvccPartitionView(
ConnectorSession session, ConnectorTableHandle handle) {
IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
try {
+ // PERF-06 cache A: memoize the BUILT derived view keyed by (db, table, snapshotId, schemaId) -- pure
+ // function of the pinned MVCC coordinate (a new snapshot/schema yields a new key, never a stale hit).
+ // The lookup sits INSIDE executeAuthenticated so a miss runs the loader (resolveTableForRead + the
+ // remote PARTITIONS build) under the FE-injected auth scope; a hit returns without any remote call. A
+ // null cache (session=user / no-cache catalog) computes directly every call. -1 (empty table / unpinned)
+ // enumerates the current snapshot and caches a trivially-empty view (harmless; REFRESH re-pins).
return context.executeAuthenticated(() -> {
- Table table = resolveTableForRead(session, iceHandle);
- return Optional.of(IcebergPartitionUtils.buildMvccPartitionView(table, iceHandle.getSnapshotId(),
- TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache));
+ if (mvccPartitionViewCache == null) {
+ return Optional.of(buildMvccPartitionViewUncached(session, iceHandle));
+ }
+ PartitionViewCacheKey key = new PartitionViewCacheKey(iceHandle.getDbName(),
+ iceHandle.getTableName(), iceHandle.getSnapshotId(), iceHandle.getSchemaId());
+ return Optional.of(mvccPartitionViewCache.get(key,
+ () -> buildMvccPartitionViewUncached(session, iceHandle)));
});
} catch (Exception e) {
throw new RuntimeException("Failed to build iceberg MVCC partition view, error message is:"
@@ -1531,6 +1570,19 @@ public Optional getMvccPartitionView(
}
}
+ /**
+ * Builds the derived MVCC partition view live (no cache-A layer): the same resolveTableForRead + remote
+ * PARTITIONS build the pre-cache code ran. Extracted so it can serve as cache A's per-miss loader. MUST run
+ * inside {@code context.executeAuthenticated} (the caller wraps it). A {@code NoSuchTableException} propagates
+ * (fail-loud parity) and, running through the framework loader, is never cached.
+ */
+ private ConnectorMvccPartitionView buildMvccPartitionViewUncached(
+ ConnectorSession session, IcebergTableHandle iceHandle) {
+ Table table = resolveTableForRead(session, iceHandle);
+ return IcebergPartitionUtils.buildMvccPartitionView(table, iceHandle.getSnapshotId(),
+ TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache);
+ }
+
/**
* The physical iceberg partition display names ({@code "f1=v1/f2=v2"}) for SHOW PARTITIONS (single-column
* form — iceberg does not declare {@code SUPPORTS_PARTITION_STATS}). Post-cutover this restores real rows
@@ -1574,17 +1626,18 @@ public List listPartitions(ConnectorSession session,
ConnectorTableHandle handle, Optional filter) {
IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
try {
+ // PERF-06 cache A: memoize the BUILT partition-info list keyed by (db, table, snapshotId, schemaId).
+ // The lookup sits INSIDE executeAuthenticated (a miss runs the remote build under the auth scope; a hit
+ // returns without a remote call). BYPASS the cache when the filter is present -- that is not the
+ // pruning path (which always passes Optional.empty()) and is not keyed by (snapshot, schema) alone -- or
+ // when the cache is null (session=user / no-cache catalog): compute directly every call.
return context.executeAuthenticated(() -> {
- Table table;
- try {
- table = resolveTableForRead(session, iceHandle);
- } catch (NoSuchTableException e) {
- LOG.warn("Iceberg table not found while listing partitions: {}.{}",
- iceHandle.getDbName(), iceHandle.getTableName(), e);
- return Collections.emptyList();
+ if (listPartitionsViewCache == null || filter.isPresent()) {
+ return listPartitionsUncached(session, iceHandle);
}
- return IcebergPartitionUtils.listPartitions(table,
- TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache);
+ PartitionViewCacheKey key = new PartitionViewCacheKey(iceHandle.getDbName(),
+ iceHandle.getTableName(), iceHandle.getSnapshotId(), iceHandle.getSchemaId());
+ return listPartitionsViewCache.get(key, () -> listPartitionsUncached(session, iceHandle));
});
} catch (Exception e) {
throw new RuntimeException("Failed to list iceberg partitions, error message is:"
@@ -1592,6 +1645,26 @@ public List listPartitions(ConnectorSession session,
}
}
+ /**
+ * Builds the derived partition-info list live (no cache-A layer): the same resolveTableForRead + remote
+ * PARTITIONS build the pre-cache code ran, including the concurrent-drop degrade (a {@code NoSuchTableException}
+ * yields an empty list). Extracted so it can serve as cache A's per-miss loader. MUST run inside
+ * {@code context.executeAuthenticated} (the caller wraps it).
+ */
+ private List listPartitionsUncached(
+ ConnectorSession session, IcebergTableHandle iceHandle) {
+ Table table;
+ try {
+ table = resolveTableForRead(session, iceHandle);
+ } catch (NoSuchTableException e) {
+ LOG.warn("Iceberg table not found while listing partitions: {}.{}",
+ iceHandle.getDbName(), iceHandle.getTableName(), e);
+ return Collections.emptyList();
+ }
+ return IcebergPartitionUtils.listPartitions(table,
+ TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache);
+ }
+
// ========== E5: MVCC snapshots / time travel ==========
/**
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 0a22ffff2fb6e4..5d5b5285f07d43 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
@@ -17,6 +17,10 @@
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.iceberg.DataFiles;
import org.apache.iceberg.ManifestFile;
import org.apache.iceberg.PartitionSpec;
@@ -31,8 +35,10 @@
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
+import java.util.function.Supplier;
/**
* Tests IcebergConnector's T08 cache knobs: the latest-snapshot cache TTL resolution
@@ -451,4 +457,97 @@ public void refreshHooksInvalidateCommentCache() {
connector.invalidateAll();
Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything");
}
+
+ // ============ PERF-06: derived partition-view cache A (session=user gated) + invalidation ============
+
+ @Test
+ public void partitionViewCacheBuiltUnlessSessionUser() {
+ // Cache A (the derived partition-view cache) stores pure metadata (a partition view carries no
+ // FileIO/credential), so like the partition cache it stays built for a REST vended-credentials catalog. But
+ // under iceberg.rest.session=user it is an AUTHORIZATION-sensitive projection -- a shared (no user
+ // dimension) hit would disclose one user's partition view -- so BOTH typed instances are disabled (null)
+ // there. MUTATION: dropping the session=user gate -> non-null for session -> red; gating on the vended flag
+ // -> null for vended -> red.
+ IcebergConnector plain = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext());
+ Assertions.assertNotNull(plain.mvccPartitionViewCacheForTest(), "a plain catalog builds the MVCC view cache");
+ Assertions.assertNotNull(plain.listPartitionsViewCacheForTest(),
+ "a plain catalog builds the listPartitions view cache");
+ Map vended = new HashMap<>();
+ vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST);
+ vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true");
+ IcebergConnector vendedConn = new IcebergConnector(vended, new RecordingConnectorContext());
+ Assertions.assertNotNull(vendedConn.mvccPartitionViewCacheForTest(),
+ "a vended-credentials catalog still builds the MVCC view cache (metadata carries no credentials)");
+ Assertions.assertNotNull(vendedConn.listPartitionsViewCacheForTest(),
+ "a vended-credentials catalog still builds the listPartitions view cache");
+ Map session = new HashMap<>();
+ session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST);
+ session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER);
+ IcebergConnector sessionConn = new IcebergConnector(session, new RecordingConnectorContext());
+ Assertions.assertNull(sessionConn.mvccPartitionViewCacheForTest(),
+ "a session=user catalog must NOT build the MVCC view cache (per-user authz must not be bypassed)");
+ Assertions.assertNull(sessionConn.listPartitionsViewCacheForTest(),
+ "a session=user catalog must NOT build the listPartitions view cache");
+ }
+
+ @Test
+ public void refreshHooksInvalidatePartitionViewCache() {
+ // The REFRESH hooks must clear cache A too (else external DDL/writes stay invisible beyond the pin): REFRESH
+ // TABLE drops that table's snapshot entries, REFRESH DATABASE that db's, REFRESH CATALOG everything. Asserted
+ // via a counting loader (the framework's size() is package-private): after 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.
+ IcebergConnector connector =
+ new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext());
+ ConnectorPartitionViewCache> 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);
+
+ // REFRESH TABLE db1.t1 -> only db1.t1 re-loads.
+ cache.get(db1t1, loader);
+ cache.get(db1t1, loader);
+ Assertions.assertEquals(1, loads[0], "second get is a hit");
+ connector.invalidateTable("db1", "t1");
+ cache.get(db1t1, loader);
+ Assertions.assertEquals(2, loads[0], "REFRESH TABLE forces a reload of db1.t1");
+
+ // REFRESH DATABASE db1 -> db1.t2 re-loads; db2.t1 unaffected.
+ cache.get(db1t2, loader); // loads=3 (miss)
+ cache.get(db2t1, loader); // loads=4 (miss)
+ cache.get(db1t2, loader); // hit
+ cache.get(db2t1, loader); // hit
+ Assertions.assertEquals(4, loads[0]);
+ connector.invalidateDb("db1");
+ cache.get(db2t1, loader); // db2 untouched -> hit
+ Assertions.assertEquals(4, loads[0], "REFRESH DATABASE db1 must NOT drop db2's entries");
+ cache.get(db1t2, loader); // db1.t2 dropped -> miss
+ Assertions.assertEquals(5, loads[0], "REFRESH DATABASE db1 drops db1's entries");
+
+ // REFRESH CATALOG -> everything re-loads.
+ connector.invalidateAll();
+ cache.get(db2t1, loader);
+ Assertions.assertEquals(6, loads[0], "REFRESH CATALOG drops everything");
+ }
+
+ @Test
+ public void invalidateHooksNoThrowForSessionUserPartitionViewCaches() {
+ // Under session=user cache A's two instances are null; the invalidate hooks must null-guard them (no NPE).
+ // MUTATION: an unguarded view-cache invalidate on a nulled field -> NPE -> red.
+ Map session = new HashMap<>();
+ session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST);
+ session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER);
+ IcebergConnector connector = new IcebergConnector(session, new RecordingConnectorContext());
+ Assertions.assertNull(connector.mvccPartitionViewCacheForTest());
+ Assertions.assertNull(connector.listPartitionsViewCacheForTest());
+ Assertions.assertDoesNotThrow(() -> connector.invalidateTable("db1", "t1"));
+ Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db1"));
+ Assertions.assertDoesNotThrow(connector::invalidateAll);
+ }
}
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
new file mode 100644
index 00000000000000..0ca659a7bd24f1
--- /dev/null
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
@@ -0,0 +1,260 @@
+// 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.iceberg;
+
+import org.apache.doris.connector.api.ConnectorPartitionInfo;
+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.iceberg.DataFiles;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * PERF-06 tests for the cross-query DERIVED partition-view cache ("cache A", the generic
+ * {@link ConnectorPartitionViewCache}) 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
+ * a cache HIT skips the whole loader and the {@code loadTable} count is the enumeration counter — the SAME proxy
+ * the sibling cache tests use ({@code IcebergConnectorMetadataMvccTest.beginQuerySnapshot*Cache*}). The raw
+ * partition cache (PERF-02) is passed null throughout, so the {@code loadTable} count isolates cache A's effect.
+ * The partition math/merge parity itself is covered by {@link IcebergPartitionUtilsTest}.
+ */
+public class IcebergConnectorMetadataPartitionViewCacheTest {
+
+ private static final Schema PARTITIONED_SCHEMA = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "ts", Types.TimestampType.withoutZone()));
+
+ /** A real db1.t1 partitioned by day(ts) carrying TWO snapshots (day=100, then +day=101). */
+ private static final class TwoSnap {
+ Table table;
+ long s1;
+ long s2;
+ long schemaId;
+ }
+
+ private static TwoSnap twoSnapshotTable() {
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("test", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ PartitionSpec spec = PartitionSpec.builderFor(PARTITIONED_SCHEMA).day("ts").build();
+ Table table = catalog.createTable(TableIdentifier.of("db1", "t1"), PARTITIONED_SCHEMA, spec);
+ table.newAppend().appendFile(dayFile(spec, "s3://b/db1/t1/f1.parquet", "ts_day=1970-04-11")).commit();
+ TwoSnap f = new TwoSnap();
+ f.s1 = table.currentSnapshot().snapshotId();
+ table.newAppend().appendFile(dayFile(spec, "s3://b/db1/t1/f2.parquet", "ts_day=1970-04-12")).commit();
+ f.s2 = table.currentSnapshot().snapshotId();
+ f.schemaId = table.schema().schemaId();
+ f.table = catalog.loadTable(TableIdentifier.of("db1", "t1"));
+ Assertions.assertNotEquals(f.s1, f.s2, "two appends must create two distinct snapshots");
+ return f;
+ }
+
+ private static org.apache.iceberg.DataFile dayFile(PartitionSpec spec, String path, String partitionPath) {
+ return DataFiles.builder(spec)
+ .withPath(path).withFileSizeInBytes(100).withRecordCount(1)
+ .withPartitionPath(partitionPath).withFormat(FileFormat.PARQUET).build();
+ }
+
+ private static IcebergConnectorMetadata metadataWithMvccCache(RecordingIcebergCatalogOps ops,
+ ConnectorPartitionViewCache 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(),
+ new IcebergLatestSnapshotCache(0L, 1), null, null, null, cache, null);
+ }
+
+ private static IcebergConnectorMetadata metadataWithListCache(RecordingIcebergCatalogOps ops,
+ ConnectorPartitionViewCache> cache) {
+ return new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext(),
+ new IcebergLatestSnapshotCache(0L, 1), null, null, null, null, cache);
+ }
+
+ private static IcebergTableHandle handle() {
+ return new IcebergTableHandle("db1", "t1");
+ }
+
+ 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 ConnectorPartitionViewCache> listCache() {
+ return new ConnectorPartitionViewCache<>("iceberg", Collections.emptyMap());
+ }
+
+ private static List mvccNames(Optional view) {
+ return view.get().getPartitions().stream()
+ .map(ConnectorMvccPartition::getName).collect(Collectors.toList());
+ }
+
+ // ---------------------------------------------------------------------
+ // getMvccPartitionView
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void getMvccPartitionViewCachesDerivedViewAcrossQueries() {
+ // WHY: cache A must memoize the BUILT MVCC view keyed by (db, table, snapshotId, schemaId), so a repeated
+ // query on the same pin skips the derived rebuild AND the underlying loadTable/scan. MUTATION: not
+ // consulting the cache (compute directly every call) -> loadTable runs twice -> red.
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ ConnectorPartitionViewCache cache = mvccCache();
+ IcebergConnectorMetadata md = metadataWithMvccCache(ops, cache);
+
+ Optional first = md.getMvccPartitionView(null, handle());
+ Optional second = md.getMvccPartitionView(null, handle());
+
+ Assertions.assertEquals(java.util.Arrays.asList("ts_day=100", "ts_day=101"), mvccNames(first));
+ Assertions.assertEquals(mvccNames(first), mvccNames(second), "the cached view is returned verbatim");
+ Assertions.assertEquals(1, loadCount(ops), "a cache hit must not re-enumerate (loadTable once)");
+ }
+
+ @Test
+ public void getMvccPartitionViewDifferentSnapshotReEnumerates() {
+ // WHY: pinning a different snapshot id yields a different cache key (time-travel must not serve another
+ // snapshot's view), so each pin re-enumerates. MUTATION: keying on (db,table) only -> the S1 view would be
+ // served for the S2 pin -> names/loadCount below red.
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ IcebergConnectorMetadata md = metadataWithMvccCache(ops, mvccCache());
+
+ IcebergTableHandle atS1 = handle().withSnapshot(f.s1, null, f.schemaId);
+ IcebergTableHandle atS2 = handle().withSnapshot(f.s2, null, f.schemaId);
+ List namesS1 = mvccNames(md.getMvccPartitionView(null, atS1));
+ List namesS2 = mvccNames(md.getMvccPartitionView(null, atS2));
+
+ Assertions.assertEquals(Collections.singletonList("ts_day=100"), namesS1);
+ Assertions.assertEquals(java.util.Arrays.asList("ts_day=100", "ts_day=101"), namesS2);
+ Assertions.assertEquals(2, loadCount(ops), "distinct snapshot keys must each enumerate");
+ }
+
+ @Test
+ public void getMvccPartitionViewInvalidateTableForcesReEnumeration() {
+ // WHY: REFRESH TABLE (Connector.invalidateTable -> cache.invalidateTable) must drop the cached view so the
+ // next query re-enumerates live. MUTATION: invalidateTable not wired -> the second call hits the stale
+ // entry -> loadCount stays 1 -> red.
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ ConnectorPartitionViewCache cache = mvccCache();
+ IcebergConnectorMetadata md = metadataWithMvccCache(ops, cache);
+
+ md.getMvccPartitionView(null, handle());
+ cache.invalidateTable("db1", "t1");
+ md.getMvccPartitionView(null, handle());
+ Assertions.assertEquals(2, loadCount(ops), "invalidateTable must force a re-enumeration");
+ }
+
+ @Test
+ public void getMvccPartitionViewNullCacheEnumeratesEveryCall() {
+ // The session=user analogue at the metadata level: a null cache (IcebergConnector passes null under
+ // iceberg.rest.session=user) means compute directly every call -> loadTable on every query (no cross-query
+ // sharing). MUTATION: defaulting to a shared cache when null was intended -> loadCount 1 -> red.
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ IcebergConnectorMetadata md = metadataWithMvccCache(ops, null);
+ md.getMvccPartitionView(null, handle());
+ md.getMvccPartitionView(null, handle());
+ Assertions.assertEquals(2, loadCount(ops), "a null (disabled) cache must re-enumerate every call");
+ }
+
+ // ---------------------------------------------------------------------
+ // listPartitions
+ // ---------------------------------------------------------------------
+
+ @Test
+ public void listPartitionsCachesDerivedListAcrossQueries() {
+ // WHY: the empty-filter pruning path must memoize the built partition-info list. MUTATION: not consulting
+ // the cache -> loadTable twice -> red.
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ ConnectorPartitionViewCache> cache = listCache();
+ IcebergConnectorMetadata md = metadataWithListCache(ops, cache);
+
+ List first = md.listPartitions(null, handle(), Optional.empty());
+ List second = md.listPartitions(null, handle(), Optional.empty());
+
+ List names = first.stream().map(ConnectorPartitionInfo::getPartitionName).collect(Collectors.toList());
+ Assertions.assertEquals(java.util.Arrays.asList("ts_day=100", "ts_day=101"), names);
+ Assertions.assertEquals(names,
+ second.stream().map(ConnectorPartitionInfo::getPartitionName).collect(Collectors.toList()));
+ Assertions.assertEquals(1, loadCount(ops), "a cache hit must not re-enumerate (loadTable once)");
+ }
+
+ @Test
+ public void listPartitionsWithFilterBypassesCache() {
+ // WHY: only the empty-filter pruning path is cached; a non-empty filter must BYPASS the cache and compute
+ // directly (the pruning path always passes Optional.empty()). MUTATION: caching regardless of filter ->
+ // the second filtered call hits -> loadCount 1 + cache.size 1 -> red.
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ ConnectorPartitionViewCache> cache = listCache();
+ IcebergConnectorMetadata md = metadataWithListCache(ops, cache);
+
+ ConnectorExpression filter = Collections::emptyList; // any non-empty filter
+ md.listPartitions(null, handle(), Optional.of(filter));
+ md.listPartitions(null, handle(), Optional.of(filter));
+ Assertions.assertEquals(2, loadCount(ops), "a present filter must bypass the cache (compute every call)");
+ // A bypassed call must also NOT populate the cache: a following empty-filter (pruning) call must MISS
+ // (loadCount 3), not be served from a filtered-populated entry.
+ md.listPartitions(null, handle(), Optional.empty());
+ Assertions.assertEquals(3, loadCount(ops), "the empty-filter call must miss (filtered calls never populate)");
+ }
+
+ @Test
+ public void listPartitionsInvalidateAllForcesReEnumeration() {
+ // WHY: REFRESH CATALOG (Connector.invalidateAll -> cache.invalidateAll) must drop the cached list.
+ // MUTATION: invalidateAll not wired -> the second call hits -> loadCount stays 1 -> red.
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ ConnectorPartitionViewCache> cache = listCache();
+ IcebergConnectorMetadata md = metadataWithListCache(ops, cache);
+
+ md.listPartitions(null, handle(), Optional.empty());
+ cache.invalidateAll();
+ md.listPartitions(null, handle(), Optional.empty());
+ Assertions.assertEquals(2, loadCount(ops), "invalidateAll must force a re-enumeration");
+ }
+}
From e3be4ad9b008564cd5429331f8df9eafe292f08c Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 03:22:13 +0800
Subject: [PATCH 13/19] [perf](catalog) wire cross-query derived partition-view
cache A into the paimon connector (S4)
Layer the generic ConnectorPartitionViewCache (S2 framework) above paimon's raw
catalogOps.listPartitions round-trip so a repeated query on a partitioned paimon table
skips the derived-view BUILD (display-name rendering + null-sentinel normalization,
PaimonConnectorMetadata#collectPartitions), not just avoids re-deriving from an
already-cached remote result -- there was no such cache before. ONE typed field (unlike
iceberg's two): paimon does not override getMvccPartitionView (the generic MTMV model
falls back to its default listPartitions/LIST/timestamp path for paimon), so
listPartitions is the only enumeration hook to wrap.
- Key = (db, table, snapshotId, schemaId). snapshotId reads the SAME per-catalog
PaimonLatestSnapshotCache that beginQuerySnapshot pins queries to -- collectPartitions'
remote call is base-identifier-only (it does not apply the handle's pinned scanOptions,
unlike the scan path) so it always reflects the CURRENT catalog state; keying off
"current" (not whatever pin happens to be on the handle) keeps the cache key an honest
descriptor of what was actually computed. schemaId is pinned -1 ("unversioned" for that
axis): PaimonTableHandle carries no schemaId field, applySnapshot never threads one onto
it, and beginQuerySnapshot's own ConnectorMvccSnapshot leaves it at the builder default
(-1) for the common latest-pin path -- and collectPartitions' output does not depend on
schema version in practice (paimon partition columns are immutable post-creation).
- listPartitions: bypass cache when filter.isPresent() (mirrors the iceberg pattern; not
currently load-bearing since paimon ignores the filter value, but keeps the two paths
independent) and when the handle is unpartitioned (preserves the pre-existing "no seam
call for unpartitioned tables" contract).
- Paimon has NO iceberg.rest.session=user-style per-user cache-disabling convention (a
paimon catalog authenticates at catalog-creation time -- Kerberos UGI / HMS principal --
not per-query session identity), so the cache is constructed unconditionally on
PaimonConnector (never null there); PaimonConnectorMetadata's convenience/test ctors
still pass null (no cross-query layer) for the ~15 existing direct-construction tests.
- Invalidation routed from invalidateTable/invalidateDb/invalidateAll to the new cache
alongside the existing latestSnapshotCache/schemaAtMemo.
Tests: new PaimonConnectorMetadataPartitionViewCacheTest (7) proves same-key-hit
enumerates once, distinct-latest-snapshot re-enumerates, invalidateTable/All
re-enumerate, filter bypass, null-cache re-enumeration, and the unpartitioned
fast path touches no remote seam; PaimonConnectorCacheTest (+2) proves the cache is
always built (no session=user gate) and the 3-hook invalidation routing on the real
field. Full module suite 370/370 green (1 pre-existing skip, PaimonLiveConnectivityTest).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014ZngdXGBAXNDRe7jA42mAS
---
.../connector/paimon/PaimonConnector.java | 31 ++-
.../paimon/PaimonConnectorMetadata.java | 74 ++++-
.../paimon/PaimonConnectorCacheTest.java | 66 +++++
...nnectorMetadataPartitionViewCacheTest.java | 262 ++++++++++++++++++
4 files changed, 431 insertions(+), 2 deletions(-)
create mode 100644 fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java
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 58df5e326b111d..12c85993a69f94 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
@@ -20,9 +20,11 @@
import org.apache.doris.connector.api.Connector;
import org.apache.doris.connector.api.ConnectorCapability;
import org.apache.doris.connector.api.ConnectorMetadata;
+import org.apache.doris.connector.api.ConnectorPartitionInfo;
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.metastore.HmsMetaStoreProperties;
import org.apache.doris.connector.metastore.spi.JdbcDriverSupport;
import org.apache.doris.connector.metastore.spi.MetaStoreProviders;
@@ -50,6 +52,7 @@
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
@@ -126,6 +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
+ // 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
+ // 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;
+
public PaimonConnector(Map properties, ConnectorContext context) {
this.properties = properties;
// Wrap the FE-injected context so every executeAuthenticated pins the TCCL to the plugin loader (the
@@ -137,6 +153,9 @@ public PaimonConnector(Map properties, ConnectorContext context)
this::pluginAuthenticator);
this.latestSnapshotCache =
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);
}
/**
@@ -225,7 +244,7 @@ private static long resolveTableCacheTtlSecond(Map properties) {
public ConnectorMetadata getMetadata(ConnectorSession session) {
return new PaimonConnectorMetadata(
new PaimonCatalogOps.CatalogBackedPaimonCatalogOps(ensureCatalog()), properties, context,
- schemaAtMemo, latestSnapshotCache);
+ schemaAtMemo, latestSnapshotCache, partitionViewCache);
}
@Override
@@ -239,6 +258,9 @@ public void invalidateTable(String dbName, String tableName) {
// (db,table,sysTable,branch,schemaId) and would otherwise serve a stale schema-at-snapshot after a
// drop+recreate that reuses a schemaId (the memo's narrow write-once-per-schemaId assumption breaks).
schemaAtMemo.invalidate(dbName, tableName);
+ // PERF-06: also drop this table's cached derived partition-view entries (every snapshotId cached for
+ // it), so the next listPartitions re-enumerates live.
+ partitionViewCache.invalidateTable(dbName, tableName);
}
/**
@@ -255,12 +277,14 @@ public void invalidateTable(String dbName, String tableName) {
public void invalidateDb(String dbName) {
latestSnapshotCache.invalidateDb(dbName);
schemaAtMemo.invalidateDb(dbName);
+ partitionViewCache.invalidateDb(dbName);
}
@Override
public void invalidateAll() {
latestSnapshotCache.invalidateAll();
schemaAtMemo.invalidateAll();
+ partitionViewCache.invalidateAll();
}
@Override
@@ -316,6 +340,11 @@ public Set getCapabilities() {
ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL);
}
+ /** Test-only: the derived listPartitions view cache (PERF-06). Never null (paimon has no session=user gate). */
+ ConnectorPartitionViewCache> partitionViewCacheForTest() {
+ return partitionViewCache;
+ }
+
private Catalog ensureCatalog() {
if (catalog == null) {
synchronized (this) {
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 c2e1fe021da017..bd59897e3f03ec 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,6 +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.spi.ConnectorContext;
import org.apache.doris.thrift.THiveTable;
import org.apache.doris.thrift.TTableDescriptor;
@@ -95,6 +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
+ // 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;
+
public PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties,
ConnectorContext context) {
this(catalogOps, properties, context, new PaimonSchemaAtMemo(PaimonSchemaAtMemo.DEFAULT_MAX_SIZE));
@@ -105,15 +116,30 @@ public PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map
this(catalogOps, properties, context, schemaAtMemo, new PaimonLatestSnapshotCache(0L, 1));
}
+ /** Convenience ctor without the PERF-06 derived partition-view cache (null -> listPartitions always live). */
PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties,
ConnectorContext context, PaimonSchemaAtMemo schemaAtMemo,
PaimonLatestSnapshotCache latestSnapshotCache) {
+ this(catalogOps, properties, context, schemaAtMemo, latestSnapshotCache, null);
+ }
+
+ /**
+ * Full ctor used by {@link PaimonConnector#getMetadata}, adding the PERF-06 derived partition-view cache
+ * (cache A): {@code partitionViewCache} memoizes {@link #listPartitions}'s built
+ * {@code List}, keyed by {@code (db, table, snapshotId, schemaId)}. {@code null}
+ * for the convenience/test ctors (no cross-query derived layer -> compute directly every call).
+ */
+ PaimonConnectorMetadata(PaimonCatalogOps catalogOps, Map properties,
+ ConnectorContext context, PaimonSchemaAtMemo schemaAtMemo,
+ PaimonLatestSnapshotCache latestSnapshotCache,
+ ConnectorPartitionViewCache> partitionViewCache) {
this.catalogOps = catalogOps;
this.typeMappingOptions = buildTypeMappingOptions(properties);
this.context = context;
this.catalogProperties = properties;
this.schemaAtMemo = schemaAtMemo;
this.latestSnapshotCache = latestSnapshotCache;
+ this.partitionViewCache = partitionViewCache;
}
@Override
@@ -955,11 +981,57 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH
* {@code PaimonExternalCatalog.getPaimonPartitions} returns the full partition set without
* 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).
*/
@Override
public List listPartitions(ConnectorSession session,
ConnectorTableHandle handle, Optional filter) {
- return collectPartitions((PaimonTableHandle) handle);
+ PaimonTableHandle paimonHandle = (PaimonTableHandle) handle;
+ List partitionKeys = paimonHandle.getPartitionKeys();
+ if (partitionViewCache == null || filter.isPresent()
+ || partitionKeys == null || partitionKeys.isEmpty()) {
+ return collectPartitions(paimonHandle);
+ }
+ PartitionViewCacheKey key = partitionViewCacheKey(paimonHandle);
+ return partitionViewCache.get(key, () -> collectPartitions(paimonHandle));
+ }
+
+ /**
+ * Builds cache A's key for {@code paimonHandle}: {@code (db, table, snapshotId, schemaId)}.
+ *
+ * snapshotId: {@link #collectPartitions}'s remote call ({@code catalogOps.listPartitions(Identifier)})
+ * is BASE-identifier-only — it does not apply the handle's pinned {@code scanOptions} (unlike the scan path),
+ * so it always reflects the CURRENT catalog state, never a time-travel pin (branch / time-travel reads never
+ * reach this path at all — see {@link #collectPartitions}). The key must therefore track "current", not
+ * whatever snapshot happens to be threaded on the handle: it reads the SAME per-catalog
+ * {@link #latestSnapshotCache} that {@link #beginQuerySnapshot} pins queries to (a cheap in-memory hit within
+ * the query — {@code beginQuerySnapshot} already warmed it), so a repeat query within the TTL hits this cache,
+ * 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}
+ * 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
+ * is not a loss for THIS view: {@link #collectPartitions} derives its output from {@code partitionKeys}
+ * (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) {
+ Identifier identifier = Identifier.create(paimonHandle.getDatabaseName(), paimonHandle.getTableName());
+ long snapshotId = latestSnapshotCache.getOrLoad(identifier,
+ () -> catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L));
+ return new PartitionViewCacheKey(
+ paimonHandle.getDatabaseName(), paimonHandle.getTableName(), snapshotId, -1L);
}
@Override
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 7e7aa033229238..f952e6d47a7def 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
@@ -17,13 +17,19 @@
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.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
+import java.util.function.Supplier;
/**
* Tests PaimonConnector's FIX-4 cache knobs (CI 973411): the {@code meta.cache.paimon.table.ttl-second}
@@ -83,4 +89,64 @@ public void invalidateHooksAreNoThrowOnFreshConnector() {
Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db1"));
Assertions.assertDoesNotThrow(connector::invalidateAll);
}
+
+ // ============ PERF-06: derived partition-view cache A (no session=user gate) + invalidation ============
+
+ @Test
+ public void partitionViewCacheAlwaysBuilt() {
+ // Unlike iceberg, paimon has NO session=user / per-user credential-isolation cache-disabling
+ // convention (a paimon catalog authenticates at catalog-creation time, not per-query session
+ // identity), so the connector must construct cache A unconditionally on every flavor. MUTATION: a
+ // stray session-like gate leaving the field null on some property combination -> red.
+ Assertions.assertNotNull(connector(Collections.emptyMap()).partitionViewCacheForTest(),
+ "a fresh paimon connector must always build the partition-view cache");
+ Map withTtl = props("3600");
+ Assertions.assertNotNull(connector(withTtl).partitionViewCacheForTest(),
+ "the partition-view cache is independent of meta.cache.paimon.table.ttl-second");
+ }
+
+ @Test
+ public void refreshHooksInvalidatePartitionViewCache() {
+ // The REFRESH hooks must clear cache A too (else external DDL/writes stay invisible beyond the TTL):
+ // REFRESH TABLE drops that table's snapshot entries, REFRESH DATABASE that db's, REFRESH CATALOG
+ // everything. Asserted via a counting loader (the framework's size() is package-private): after
+ // 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();
+ 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);
+
+ // REFRESH TABLE db1.t1 -> only db1.t1 re-loads.
+ cache.get(db1t1, loader);
+ cache.get(db1t1, loader);
+ Assertions.assertEquals(1, loads[0], "second get is a hit");
+ connector.invalidateTable("db1", "t1");
+ cache.get(db1t1, loader);
+ Assertions.assertEquals(2, loads[0], "REFRESH TABLE forces a reload of db1.t1");
+
+ // REFRESH DATABASE db1 -> db1.t2 re-loads; db2.t1 unaffected.
+ cache.get(db1t2, loader); // loads=3 (miss)
+ cache.get(db2t1, loader); // loads=4 (miss)
+ cache.get(db1t2, loader); // hit
+ cache.get(db2t1, loader); // hit
+ Assertions.assertEquals(4, loads[0]);
+ connector.invalidateDb("db1");
+ cache.get(db2t1, loader); // db2 untouched -> hit
+ Assertions.assertEquals(4, loads[0], "REFRESH DATABASE db1 must NOT drop db2's entries");
+ cache.get(db1t2, loader); // db1.t2 dropped -> miss
+ Assertions.assertEquals(5, loads[0], "REFRESH DATABASE db1 drops db1's entries");
+
+ // REFRESH CATALOG -> everything re-loads.
+ connector.invalidateAll();
+ cache.get(db2t1, loader);
+ Assertions.assertEquals(6, loads[0], "REFRESH CATALOG drops everything");
+ }
}
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
new file mode 100644
index 00000000000000..e93126549df16d
--- /dev/null
+++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java
@@ -0,0 +1,262 @@
+// 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.paimon;
+
+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.paimon.partition.Partition;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.stream.Collectors;
+
+/**
+ * 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.
+ *
+ * Uses the real {@link RecordingPaimonCatalogOps} + {@link FakePaimonTable} harness (no Mockito, no docker):
+ * {@link PaimonConnectorMetadata#collectPartitions}'s first (and only) remote call is
+ * {@code catalogOps.listPartitions(Identifier)}, logged as {@code "listPartitions:db1.t1"}, so a cache HIT skips
+ * the whole loader and that log count is the enumeration counter — the SAME proxy
+ * {@link PaimonConnectorMetadataPartitionTest} already relies on ({@code ops.log.contains("listPartitions:...")}).
+ *
+ *
Every test uses a DISABLED {@link PaimonLatestSnapshotCache} ({@code ttl-second <= 0}, always-live) so the
+ * cache-A key's {@code snapshotId} component is driven directly and deterministically by
+ * {@code ops.latestSnapshotId}, isolating cache A (the only caching layer under test) — mirroring how the
+ * iceberg PERF-06 tests pass the raw partition cache as {@code null} throughout.
+ */
+public class PaimonConnectorMetadataPartitionViewCacheTest {
+
+ private static PaimonConnectorMetadata metadataWithCache(RecordingPaimonCatalogOps ops,
+ ConnectorPartitionViewCache> 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 RowType regionRowType() {
+ return RowType.builder()
+ .field("id", DataTypes.INT())
+ .field("region", DataTypes.STRING())
+ .build();
+ }
+
+ private static PaimonTableHandle handle(FakePaimonTable table) {
+ PaimonTableHandle h = new PaimonTableHandle(
+ "db1", "t1", Collections.singletonList("region"), Collections.emptyList());
+ h.setPaimonTable(table);
+ return h;
+ }
+
+ private static FakePaimonTable regionTable() {
+ FakePaimonTable table = new FakePaimonTable(
+ "t1", regionRowType(), Collections.singletonList("region"), Collections.emptyList());
+ table.setOptions(Collections.singletonMap("partition.legacy-name", "true"));
+ return table;
+ }
+
+ private static Partition partition(String regionValue) {
+ Map spec = new LinkedHashMap<>();
+ spec.put("region", regionValue);
+ return new Partition(spec, 1L, 1L, 1, 1L, true);
+ }
+
+ private static long loadCount(RecordingPaimonCatalogOps ops) {
+ return ops.log.stream().filter(s -> s.equals("listPartitions:db1.t1")).count();
+ }
+
+ private static List names(List infos) {
+ return infos.stream().map(ConnectorPartitionInfo::getPartitionName).collect(Collectors.toList());
+ }
+
+ @Test
+ public void listPartitionsCachesDerivedListAcrossQueries() {
+ // WHY: cache A must memoize the BUILT List keyed by (db, table, snapshotId,
+ // schemaId), so a repeated query on the same (unchanged) latest snapshot skips the derived rebuild AND
+ // the remote catalogOps.listPartitions round-trip. MUTATION: not consulting the cache (compute directly
+ // every call) -> the seam runs twice -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Arrays.asList(partition("cn"), partition("us"));
+ ConnectorPartitionViewCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ List first = md.listPartitions(null, h, Optional.empty());
+ List second = md.listPartitions(null, h, Optional.empty());
+
+ Assertions.assertEquals(Arrays.asList("region=cn", "region=us"), names(first));
+ Assertions.assertEquals(names(first), names(second), "the cached list is returned verbatim");
+ Assertions.assertEquals(1, loadCount(ops), "a cache hit must not re-enumerate (listPartitions once)");
+ }
+
+ @Test
+ public void listPartitionsDifferentSnapshotReEnumerates() {
+ // WHY: the underlying remote enumeration always reflects the CURRENT catalog state (it is
+ // base-identifier-only, never pinned to a historical snapshot -- see partitionViewCacheKey's javadoc),
+ // so the key must track "current" via latestSnapshotCache: a data change (new latest snapshot id)
+ // must mint a new key and re-enumerate. MUTATION: keying on (db,table) only, ignoring snapshotId ->
+ // the stale S1 list would be served after the snapshot changed -> loadCount/contents below red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ConnectorPartitionViewCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Collections.singletonList(partition("cn"));
+ List atS1 = md.listPartitions(null, h, Optional.empty());
+
+ ops.latestSnapshotId = OptionalLong.of(200L);
+ ops.partitions = Arrays.asList(partition("cn"), partition("us"));
+ List atS2 = md.listPartitions(null, h, Optional.empty());
+
+ Assertions.assertEquals(Collections.singletonList("region=cn"), names(atS1));
+ Assertions.assertEquals(Arrays.asList("region=cn", "region=us"), names(atS2));
+ Assertions.assertEquals(2, loadCount(ops), "distinct snapshot keys must each enumerate");
+ }
+
+ @Test
+ public void listPartitionsInvalidateTableForcesReEnumeration() {
+ // WHY: REFRESH TABLE (PaimonConnector.invalidateTable -> cache.invalidateTable) must drop the cached
+ // list so the next query re-enumerates live. MUTATION: invalidateTable not wired -> the second call
+ // hits the stale entry -> loadCount stays 1 -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Collections.singletonList(partition("cn"));
+ ConnectorPartitionViewCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ md.listPartitions(null, h, Optional.empty());
+ cache.invalidateTable("db1", "t1");
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(2, loadCount(ops), "invalidateTable must force a re-enumeration");
+ }
+
+ @Test
+ public void listPartitionsInvalidateAllForcesReEnumeration() {
+ // WHY: REFRESH CATALOG (PaimonConnector.invalidateAll -> cache.invalidateAll) must drop the cached
+ // list. MUTATION: invalidateAll not wired -> the second call hits -> loadCount stays 1 -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Collections.singletonList(partition("cn"));
+ ConnectorPartitionViewCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ md.listPartitions(null, h, Optional.empty());
+ cache.invalidateAll();
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(2, loadCount(ops), "invalidateAll must force a re-enumeration");
+ }
+
+ @Test
+ public void listPartitionsWithFilterBypassesCache() {
+ // WHY: a present filter must BYPASS the cache and compute directly -- and must NOT populate it either,
+ // so a later empty-filter (pruning) call still misses. legacy ignores the filter value entirely
+ // (returns the full set regardless), but the cache-A key carries no filter dimension, so caching a
+ // filtered call's result would be indistinguishable from caching an unfiltered one; bypassing keeps the
+ // two paths independent, mirroring the iceberg pattern. MUTATION: caching regardless of filter -> the
+ // second filtered call hits (loadCount stays 1) -> red; or a bypassed call populating the cache -> the
+ // following empty-filter call hits instead of missing -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Collections.singletonList(partition("cn"));
+ ConnectorPartitionViewCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+ PaimonTableHandle h = handle(table);
+
+ ConnectorExpression filter = Collections::emptyList; // any non-empty filter
+ md.listPartitions(null, h, Optional.of(filter));
+ md.listPartitions(null, h, Optional.of(filter));
+ Assertions.assertEquals(2, loadCount(ops), "a present filter must bypass the cache (compute every call)");
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(3, loadCount(ops), "the empty-filter call must miss (filtered calls never populate)");
+ }
+
+ @Test
+ public void listPartitionsNullCacheEnumeratesEveryCall() {
+ // The convenience/test-ctor analogue: a null cache means compute directly every call -> the seam runs
+ // on every query (no cross-query sharing). MUTATION: defaulting to a shared cache when null was
+ // intended -> loadCount 1 -> red.
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ FakePaimonTable table = regionTable();
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(100L);
+ ops.partitions = Collections.singletonList(partition("cn"));
+ PaimonConnectorMetadata md = metadataWithCache(ops, null);
+ PaimonTableHandle h = handle(table);
+
+ md.listPartitions(null, h, Optional.empty());
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(2, loadCount(ops), "a null (disabled) cache must re-enumerate every call");
+ }
+
+ @Test
+ public void unpartitionedHandleBypassesCacheWithoutTouchingSnapshotSeam() {
+ // WHY: an unpartitioned handle must short-circuit to empty WITHOUT touching either seam
+ // (latestSnapshotId or listPartitions) -- mirrors collectPartitions' own pre-existing contract
+ // (PaimonConnectorMetadataPartitionTest#nonPartitionedHandleReturnsEmptyWithoutSeamCall). Building a
+ // cache-A key would otherwise call latestSnapshotCache -> catalogOps.latestSnapshotId for a table that
+ // is guaranteed to return an empty list, polluting the cache and the seam log for nothing. MUTATION:
+ // building the key 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);
+ ConnectorPartitionViewCache> cache = partitionViewCache();
+ PaimonConnectorMetadata md = metadataWithCache(ops, cache);
+
+ List result = md.listPartitions(null, h, Optional.empty());
+
+ Assertions.assertTrue(result.isEmpty());
+ Assertions.assertTrue(ops.log.isEmpty(), "an unpartitioned handle must not touch any remote seam");
+ }
+}
From 75fcd04e19cb0e8c7bf485cfb785426086b51112 Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 03:49:52 +0800
Subject: [PATCH 14/19] [feat](catalog) enable Cache B for snapshot-less
connectors via a partition-name-set version token
5c17b748880 disabled cross-query SortedPartitionRanges reuse (Cache B) for hive (and any
snapshot-less MVCC-wrapped table) because the "@" version token is a
constant "-1@" when the pinned connector snapshot has no real snapshot id, so a cache
hit could silently serve ranges built from a stale partition set.
PluginDrivenMvccExternalTable#getPartitionMetaVersion now derives the token from the frozen
partition NAME SET (a compact HashSet copy of getOriginPartitions(scan).keySet()) when
snapshotId == -1, instead of the constant string. Since getOriginPartitions and
getPartitionMetaVersion read the SAME pin, version == exact content the ranges are built from, so
the cache rebuilds precisely when the partition set changes -- no staleness risk, no SPI change,
no generation counter. iceberg/paimon (snapshotId != -1) keep the unchanged
"@" token.
With the version now correct for snapshot-less connectors too, the getSortedPartitionRanges
override (added in 5c17b748880 solely to gate on snapshotId == -1) is a pure pass-through to
ExternalTable's cache-manager delegation, so it is deleted.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014ZngdXGBAXNDRe7jA42mAS
---
.../mvcc/PluginDrivenMvccExternalTable.java | 41 +++---
...tedPartitionsCacheManagerExternalTest.java | 126 ++++++++++++++----
2 files changed, 118 insertions(+), 49 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java
index c6205e723a2a52..2a48a6a6072088 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java
@@ -55,7 +55,6 @@
import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot;
import org.apache.doris.mtmv.MTMVSnapshotIf;
import org.apache.doris.mtmv.MTMVTimestampSnapshot;
-import org.apache.doris.nereids.rules.expression.rules.SortedPartitionRanges;
import org.apache.doris.nereids.trees.plans.algebra.CatalogRelation;
import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan;
@@ -68,6 +67,7 @@
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -635,8 +635,17 @@ public Map, PartitionItem> getOriginPartitions(CatalogRelation scan) {
@Override
public Object getPartitionMetaVersion(CatalogRelation scan) {
ConnectorMvccSnapshot cs = getOrMaterialize(pinnedSnapshot(scan)).getConnectorSnapshot();
- // Opaque version token: iceberg/paimon expose an immutable (snapshotId, schemaId) pair.
- return cs.getSnapshotId() + "@" + cs.getSchemaId();
+ if (cs.getSnapshotId() != -1L) {
+ // Opaque version token: iceberg/paimon expose an immutable (snapshotId, schemaId) pair.
+ return cs.getSnapshotId() + "@" + cs.getSchemaId();
+ }
+ // Snapshot-less connector (e.g. hive): no real MVCC snapshot id to version against. Derive the
+ // token from the frozen partition NAME SET instead -- getOriginPartitions(scan) reads the SAME
+ // pin, so version == exact content the ranges are built from: the cache rebuilds precisely when
+ // the partition set changes, never on a stale set. A compact copy of just the name strings (NOT
+ // the live keySet view), so the cross-query cache entry does not retain the whole pin's
+ // partition-item map.
+ return new HashSet<>(getOriginPartitions(scan).keySet());
}
@Override
@@ -646,28 +655,10 @@ public long getPartitionMetaLoadTimeMillis(CatalogRelation scan) {
return 0L;
}
- @Override
- public Optional> getSortedPartitionRanges(CatalogRelation scan) {
- // Cache B (NereidsSortedPartitionsCacheManager) is keyed by getPartitionMetaVersion(scan), i.e.
- // "@". That token only detects a partition-set change when snapshotId is a
- // REAL, monotonically-changing value (iceberg/paimon). Hive's beginQuerySnapshot always pins the
- // sentinel snapshotId == -1 (hive has no MVCC snapshot), so the token would be the CONSTANT
- // "-1@" across queries -- a cache hit could then serve ranges built from an OLDER
- // partition set than the current pin's nameToPartitionItem (e.g. after a CachingHmsClient TTL
- // refresh with no invalidate event), silently pruning against a stale range set. Skip the cache
- // for a -1 (non-MVCC / sentinel) snapshot: return empty so the caller (PruneFileScanPartition)
- // builds ranges fresh from THIS query's pin every time, which is always consistent with it -- the
- // pre-cache behavior. A real snapshot id (iceberg/paimon) keeps the cache via super's delegation
- // to the cache manager. An EMPTY iceberg table also pins snapshotId == -1, but its
- // nameToPartitionItem is then empty too, and PruneFileScanPartition's
- // "!nameToPartitionItem.isEmpty()" guard already skips ranges entirely -- so gating on -1 here is
- // safe for that case as well.
- ConnectorMvccSnapshot cs = getOrMaterialize(pinnedSnapshot(scan)).getConnectorSnapshot();
- if (cs.getSnapshotId() == -1L) {
- return Optional.empty();
- }
- return super.getSortedPartitionRanges(scan);
- }
+ // getSortedPartitionRanges is now a pure pass-through to ExternalTable's cache-manager delegation
+ // (5c17b748880's snapshotId==-1 short-circuit was removed: getPartitionMetaVersion above derives a
+ // content-comparable version for snapshot-less connectors, so Cache B is now correct for them too) --
+ // no override needed here.
private Optional pinnedSnapshot(CatalogRelation scan) {
if (scan instanceof LogicalFileScan) {
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
index 5ebae7ed4361e8..9cfb03ae14d2d0 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
@@ -297,24 +297,32 @@ public void testPluginDrivenMvccExternalTableRealOriginPartitionsAndVersion() th
}
}
- // ──────────────────── Hive sentinel snapshot (-1) must skip Cache B ────────────────────
+ // ──────────────────── Hive sentinel snapshot (-1) now uses Cache B via a NAME-SET version ─────
//
- // getPartitionMetaVersion's token is "@". Hive's beginQuerySnapshot always pins
- // snapshotId == -1 (no real MVCC snapshot), so that token is a CONSTANT across queries and cannot
- // detect a partition-set change. PluginDrivenMvccExternalTable#getSortedPartitionRanges must therefore
- // gate on the pinned connector snapshot's snapshotId and return empty (skip Cache B, build fresh every
- // query) rather than delegating to the cache manager.
+ // getPartitionMetaVersion's token is "@" for a REAL snapshot id, but hive's
+ // beginQuerySnapshot always pins the sentinel snapshotId == -1 (hive has no MVCC snapshot), so that
+ // token would be a CONSTANT across queries. Instead, for a snapshot-less pin the version is derived
+ // from the frozen partition NAME SET (getOriginPartitions(scan).keySet(), copied) -- the SAME map the
+ // ranges are built from, so version == exact content and the cache rebuilds precisely when the
+ // partition set changes. PluginDrivenMvccExternalTable no longer overrides getSortedPartitionRanges
+ // (the -1 short-circuit added in 5c17b748880 was removed): a snapshot-less pin now goes through the
+ // inherited ExternalTable#getSortedPartitionRanges -> the cache manager, exactly like iceberg/paimon.
- @Test
- public void testGetSortedPartitionRangesEmptyForSentinelSnapshotId() throws Exception {
- Map pinnedParts = Maps.newHashMap();
- pinnedParts.put("id=1", listItem(1));
- // Hive's sentinel: no real MVCC snapshot id.
- ConnectorMvccSnapshot sentinelSnapshot = ConnectorMvccSnapshot.builder()
- .snapshotId(-1L).schemaId(0L).build();
- PluginDrivenMvccSnapshot pin = new PluginDrivenMvccSnapshot(
- sentinelSnapshot, pinnedParts, Maps.newHashMap());
+ /**
+ * Builds a live Env whose {@code getSortedPartitionsCacheManager()} returns {@code rangesCacheMgr},
+ * mockStatic-scoped so {@code table.getSortedPartitionRanges(scan)} (now purely inherited from
+ * {@link ExternalTable}, no override) can resolve {@code Env.getCurrentEnv()} on the real dispatch path.
+ */
+ private static MockedStatic mockEnvWithRangesCacheManager(NereidsSortedPartitionsCacheManager rangesCacheMgr) {
+ Env env = Mockito.mock(Env.class);
+ Mockito.when(env.getSortedPartitionsCacheManager()).thenReturn(rangesCacheMgr);
+ MockedStatic envStatic = Mockito.mockStatic(Env.class);
+ envStatic.when(Env::getCurrentEnv).thenReturn(env);
+ return envStatic;
+ }
+ /** Builds a {@code PluginDrivenMvccExternalTable} mock wired for the real getOriginPartitions/getPartitionMetaVersion/pinnedSnapshot bodies (same technique as the other real-wiring tests in this file). */
+ private static PluginDrivenMvccExternalTable newRealWiringTable() {
PluginDrivenMvccExternalTable table =
Mockito.mock(PluginDrivenMvccExternalTable.class, Mockito.CALLS_REAL_METHODS);
ExternalDatabase> database = Mockito.mock(ExternalDatabase.class);
@@ -325,25 +333,95 @@ public void testGetSortedPartitionRangesEmptyForSentinelSnapshotId() throws Exce
Mockito.when(database.getCatalog()).thenReturn((CatalogIf) catalog);
Mockito.when(catalog.getName()).thenReturn(CTL);
Mockito.doReturn(SelectedPartitions.NOT_PRUNED).when(table).initSelectedPartitions(Mockito.any());
+ return table;
+ }
+ /** Pins {@code pin} onto a FRESH ConnectContext/StatementContext ("a new query") and returns the scan built over it. */
+ private static LogicalFileScan pinLatestAndBuildScan(PluginDrivenMvccExternalTable table, PluginDrivenMvccSnapshot pin) {
ConnectContext ctx = new ConnectContext();
StatementContext stmtCtx = new StatementContext(ctx, null);
ctx.setStatementContext(stmtCtx);
ctx.setThreadLocalInfo();
- try {
- // B5a implicit query-begin (latest) pin -- mirrors a plain (no @tag/@branch/time-travel) hive scan.
- Mockito.doReturn(pin).when(table).loadSnapshot(Optional.empty(), Optional.empty());
- stmtCtx.loadSnapshots(table, Optional.empty(), Optional.empty());
+ // B5a implicit query-begin (latest) pin -- mirrors a plain (no @tag/@branch/time-travel) hive scan.
+ Mockito.doReturn(pin).when(table).loadSnapshot(Optional.empty(), Optional.empty());
+ stmtCtx.loadSnapshots(table, Optional.empty(), Optional.empty());
+ return new LogicalFileScan(new RelationId(1), table,
+ Collections.singletonList(DB), Collections.emptyList(),
+ Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty());
+ }
- LogicalFileScan scan = new LogicalFileScan(new RelationId(1), table,
- Collections.singletonList(DB), Collections.emptyList(),
- Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty());
+ private static PluginDrivenMvccSnapshot sentinelPin(Map parts) {
+ ConnectorMvccSnapshot sentinelSnapshot = ConnectorMvccSnapshot.builder()
+ .snapshotId(-1L).schemaId(0L).build();
+ return new PluginDrivenMvccSnapshot(sentinelSnapshot, parts, Maps.newHashMap());
+ }
+
+ @Test
+ public void testGetSortedPartitionRangesPresentForSnapshotLessNonEmptyPartitions() throws Exception {
+ Map pinnedParts = Maps.newHashMap();
+ pinnedParts.put("id=1", listItem(1));
+ pinnedParts.put("id=2", listItem(2));
+ PluginDrivenMvccExternalTable table = newRealWiringTable();
+
+ NereidsSortedPartitionsCacheManager rangesCacheMgr = new NereidsSortedPartitionsCacheManager();
+ try (MockedStatic envStatic = mockEnvWithRangesCacheManager(rangesCacheMgr)) {
+ LogicalFileScan scan = pinLatestAndBuildScan(table, sentinelPin(pinnedParts));
+
+ Optional> ranges = table.getSortedPartitionRanges(scan);
+
+ Assertions.assertTrue(ranges.isPresent(),
+ "snapshotId == -1 (hive sentinel) with a non-empty partition set must now use Cache B, "
+ + "keyed by the partition NAME-SET version token");
+ } finally {
+ ConnectContext.remove();
+ }
+ }
+
+ @Test
+ public void testGetSortedPartitionRangesRebuildsWhenSnapshotLessNameSetChanges() throws Exception {
+ PluginDrivenMvccExternalTable table = newRealWiringTable();
+ NereidsSortedPartitionsCacheManager rangesCacheMgr = new NereidsSortedPartitionsCacheManager();
+ try (MockedStatic envStatic = mockEnvWithRangesCacheManager(rangesCacheMgr)) {
+ Map partsAtQuery1 = Maps.newHashMap();
+ partsAtQuery1.put("id=1", listItem(1));
+ partsAtQuery1.put("id=2", listItem(2));
+ LogicalFileScan scan1 = pinLatestAndBuildScan(table, sentinelPin(partsAtQuery1));
+ SortedPartitionRanges first = table.getSortedPartitionRanges(scan1).orElse(null);
+ Assertions.assertNotNull(first, "ranges built and cached at the first (2-partition) name set");
+ Assertions.assertEquals(2, first.sortedPartitions.size());
+
+ // A second query ("ALTER ADD PARTITION" between queries): the pin's partition NAME SET grew.
+ // The manager's Objects.equals compares the two HashSet version tokens by CONTENT, so it must
+ // detect this change even though snapshotId is still the constant -1 on both pins.
+ Map partsAtQuery2 = Maps.newHashMap();
+ partsAtQuery2.put("id=1", listItem(1));
+ partsAtQuery2.put("id=2", listItem(2));
+ partsAtQuery2.put("id=3", listItem(3));
+ LogicalFileScan scan2 = pinLatestAndBuildScan(table, sentinelPin(partsAtQuery2));
+ SortedPartitionRanges rebuilt = table.getSortedPartitionRanges(scan2).orElse(null);
+
+ Assertions.assertNotNull(rebuilt, "ranges rebuilt at the second (3-partition) name set");
+ Assertions.assertNotSame(first, rebuilt,
+ "name-set change at a constant snapshotId==-1 must still trigger a rebuild "
+ + "(the version token is content-derived from the partition names, not the snapshot id)");
+ Assertions.assertEquals(3, rebuilt.sortedPartitions.size(), "rebuilt from the new partition set");
+ } finally {
+ ConnectContext.remove();
+ }
+ }
+
+ @Test
+ public void testGetSortedPartitionRangesEmptyForSnapshotLessEmptyPartitions() throws Exception {
+ PluginDrivenMvccExternalTable table = newRealWiringTable();
+ NereidsSortedPartitionsCacheManager rangesCacheMgr = new NereidsSortedPartitionsCacheManager();
+ try (MockedStatic envStatic = mockEnvWithRangesCacheManager(rangesCacheMgr)) {
+ LogicalFileScan scan = pinLatestAndBuildScan(table, sentinelPin(Maps.newHashMap()));
Optional> ranges = table.getSortedPartitionRanges(scan);
Assertions.assertFalse(ranges.isPresent(),
- "snapshotId == -1 (hive sentinel) must skip Cache B and yield empty, so the caller "
- + "(PruneFileScanPartition) builds ranges fresh from this query's pin");
+ "an empty partition set (snapshot-less or not) yields no ranges to cache "
+ + "(SortedPartitionRanges.build(emptyMap) == null)");
} finally {
ConnectContext.remove();
}
From cd1d080fe620463b89cebce1db4c1f684cf786b7 Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 04:09:24 +0800
Subject: [PATCH 15/19] [perf](catalog) wire cross-query derived partition-view
cache A into the hive connector (S6)
Layer the generic ConnectorPartitionViewCache (S2 framework) above hive's raw per-name
HMS listing (already cached by CachingHmsClient) so a repeated query on a partitioned hive
table skips the derived-view BUILD -- the per-name HiveWriteUtils.toPartitionValues parse +
ConnectorPartitionInfo construction in HiveConnectorMetadata#listPartitions -- not just the
metastore round-trip. Mirrors the paimon wiring (S4, e3be4ad9b00): ONE typed field (hive's
getMvccPartitionView returns the SPI default Optional.empty() for a real hive handle, so
listPartitions is the only enumeration hook to wrap).
- Key = (db, table, -1, -1). Hive is snapshot-less (beginQuerySnapshot always pins -1) and
HiveTableHandle carries no schema version, so both axes are pinned "unversioned" --
simpler than paimon (no per-catalog snapshot lookup needed to build the key) and there is
no cross-caller keying concern since every query for a table always shares one key.
- listPartitions: extracted the existing body into listPartitionsUncached (the derivation
seam); the public method bypasses the cache (compute directly, never populated) when
partitionViewCache is null (the convenience/test ctors) or filter is present.
- Hive has NO iceberg.rest.session=user-style per-user cache-disabling convention (its
per-catalog caches -- CachingHmsClient, HiveFileListingCache -- are already built
unconditionally), so the cache is constructed unconditionally on HiveConnector.
- Invalidation routed from invalidateTable/invalidateDb/invalidateAll to the new cache
alongside CachingHmsClient/HiveFileListingCache; invalidatePartition (hive overrides the
SPI no-op default) also routes to invalidateTable -- cache A's key carries no
partition-name axis, so a partition-level change can only invalidate the whole table's
single entry.
Tests: new HiveConnectorMetadataPartitionViewCacheTest (5) proves same-key-hit enumerates
once (via a call-counting HmsClient double, the same FakeHmsClient idiom
HiveConnectorMetadataPartitionListTest uses), invalidateTable/invalidateAll re-enumerate,
filter bypass (both directions), and a null cache re-enumerates every call;
HiveConnectorPartitionViewCacheTest (4) proves the cache is always built (no session=user
gate), the REFRESH TABLE/DATABASE/CATALOG hooks route correctly on the real field, and
invalidatePartition drops the whole table's entry. Confirmed RED without the wiring
(compile failure: no matching ctor / no partitionViewCacheForTest) before restoring the
implementation. Full fe-connector-hive suite 360/360 green; checkstyle clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014ZngdXGBAXNDRe7jA42mAS
---
.../doris/connector/hive/HiveConnector.java | 33 ++-
.../connector/hive/HiveConnectorMetadata.java | 48 ++++
...nnectorMetadataPartitionViewCacheTest.java | 251 ++++++++++++++++++
.../HiveConnectorPartitionViewCacheTest.java | 144 ++++++++++
4 files changed, 475 insertions(+), 1 deletion(-)
create mode 100644 fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionViewCacheTest.java
create mode 100644 fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPartitionViewCacheTest.java
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 1ba13db82c1b64..b11a66c3d8e348 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
@@ -20,6 +20,7 @@
import org.apache.doris.connector.api.Connector;
import org.apache.doris.connector.api.ConnectorCapability;
import org.apache.doris.connector.api.ConnectorMetadata;
+import org.apache.doris.connector.api.ConnectorPartitionInfo;
import org.apache.doris.connector.api.ConnectorSession;
import org.apache.doris.connector.api.ConnectorTestResult;
import org.apache.doris.connector.api.DorisConnectorException;
@@ -28,6 +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.hms.CachingHmsClient;
import org.apache.doris.connector.hms.HmsClient;
import org.apache.doris.connector.hms.HmsClientConfig;
@@ -96,6 +98,19 @@ public class HiveConnector implements Connector {
// enters SPI_READY_TYPES. Its 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
+ // 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
+ // (db, table, -1, -1) — hive is snapshot-less (beginQuerySnapshot always pins -1) and its handle carries no
+ // schema version, so every query for a table shares ONE entry, invalidated by the hooks below + TTL. ONE
+ // typed field (like paimon): hive's getMvccPartitionView returns the SPI default (Optional.empty()) for a
+ // real hive handle, so listPartitions is the only enumeration hook to wrap. Unlike iceberg, hive has NO
+ // 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;
+
// 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
// context.createSiblingConnector — never co-packaged into the hive zip (a second AWS SDK would poison S3
@@ -116,6 +131,9 @@ public HiveConnector(Map properties, ConnectorContext context) {
this.properties = Collections.unmodifiableMap(properties);
this.context = 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);
}
@Override
@@ -170,7 +188,7 @@ private static String rootCauseMessage(Throwable t) {
HiveConnectorMetadata newMetadata(HmsClient client) {
return new HiveConnectorMetadata(client, properties, context,
this::getOrCreateIcebergSibling, this::getOrCreateHudiSibling, this::resolveSiblingOwnerLabeled,
- fileListingCache);
+ fileListingCache, partitionViewCache);
}
/**
@@ -351,6 +369,9 @@ void invalidateTable(HmsClient client, String dbName, String tableName) {
((CachingHmsClient) client).flush(dbName, tableName);
}
fileListingCache.invalidateTable(dbName, tableName);
+ // PERF-06: also drop this table's cached derived partition-view entry, so the next listPartitions
+ // re-derives live.
+ partitionViewCache.invalidateTable(dbName, tableName);
}
/**
@@ -373,6 +394,7 @@ void invalidateDb(HmsClient client, String dbName) {
((CachingHmsClient) client).flushDb(dbName);
}
fileListingCache.invalidateDb(dbName);
+ partitionViewCache.invalidateDb(dbName);
}
/**
@@ -393,6 +415,7 @@ void invalidateAll(HmsClient client) {
((CachingHmsClient) client).flushAll();
}
fileListingCache.invalidateAll();
+ partitionViewCache.invalidateAll();
}
/**
@@ -422,6 +445,9 @@ void invalidatePartition(HmsClient client, String dbName, String tableName, List
((CachingHmsClient) client).invalidatePartitions(dbName, tableName, partitionValues);
}
fileListingCache.invalidatePartitions(dbName, tableName, partitionValues);
+ // PERF-06: cache A's key carries no partition-name axis (only db/table/-1/-1), so a partition-level
+ // change cannot be scoped finer than the whole table's single cached entry — invalidate it wholesale.
+ partitionViewCache.invalidateTable(dbName, tableName);
}
/**
@@ -473,6 +499,11 @@ HiveFileListingCache fileListingCacheForTest() {
return fileListingCache;
}
+ /** Test-only: the derived listPartitions view cache (PERF-06). Never null (hive has no session=user gate). */
+ ConnectorPartitionViewCache> partitionViewCacheForTest() {
+ return partitionViewCache;
+ }
+
private HmsClient getOrCreateClient() {
if (hmsClient == null) {
synchronized (this) {
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 07f50ca76ca4a2..00cb6b9ee8c1ec 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,6 +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.hms.HiveShowCreateTableRenderer;
import org.apache.doris.connector.hms.HmsClient;
import org.apache.doris.connector.hms.HmsClientException;
@@ -225,6 +227,17 @@ 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
+ // 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
+ // parse + ConnectorPartitionInfo construction in listPartitions) and the collectPartitionNames call, keyed by
+ // (db, table, snapshotId=-1, schemaId=-1) — hive is snapshot-less (beginQuerySnapshot always pins -1) and its
+ // 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;
+
public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context) {
this(hmsClient, properties, context, NO_ICEBERG_SIBLING, NO_HUDI_SIBLING, NO_SIBLING_OWNER);
}
@@ -237,11 +250,28 @@ public HiveConnectorMetadata(HmsClient hmsClient, Map properties
new HiveFileListingCache(properties));
}
+ /** Convenience ctor without the PERF-06 derived partition-view cache (null -> listPartitions always live). */
public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context,
Supplier icebergSiblingSupplier,
Supplier hudiSiblingSupplier,
Function siblingOwnerResolver,
HiveFileListingCache fileListingCache) {
+ this(hmsClient, properties, context, icebergSiblingSupplier, hudiSiblingSupplier, siblingOwnerResolver,
+ fileListingCache, null);
+ }
+
+ /**
+ * Full ctor used by {@link HiveConnector#newMetadata}, adding the PERF-06 derived partition-view cache
+ * (cache A): {@code partitionViewCache} memoizes {@link #listPartitions}'s built
+ * {@code List}, keyed by {@code (db, table, -1, -1)}. {@code null} for the
+ * convenience/test ctors (no cross-query derived layer -> compute directly every call).
+ */
+ public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context,
+ Supplier icebergSiblingSupplier,
+ Supplier hudiSiblingSupplier,
+ Function siblingOwnerResolver,
+ HiveFileListingCache fileListingCache,
+ ConnectorPartitionViewCache> partitionViewCache) {
this.hmsClient = hmsClient;
this.properties = properties;
this.context = context;
@@ -249,6 +279,7 @@ public HiveConnectorMetadata(HmsClient hmsClient, Map properties
this.hudiSiblingSupplier = hudiSiblingSupplier;
this.siblingOwnerResolver = siblingOwnerResolver;
this.fileListingCache = fileListingCache;
+ this.partitionViewCache = partitionViewCache;
}
/**
@@ -1134,6 +1165,13 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH
* (until then a hive MTMV base table's per-partition freshness is UNKNOWN, harmless while hive is
* dormant). {@code rowCount}/{@code sizeBytes}/{@code fileCount} are likewise UNKNOWN — hive does not
* declare {@code SUPPORTS_PARTITION_STATS} (legacy SHOW PARTITIONS lists names only).
+ *
+ * PERF-06 cache A: the BUILT {@code List} is memoized across queries in
+ * {@link #partitionViewCache}, keyed by {@code (db, table, -1, -1)} (hive is snapshot-less and carries no
+ * schema version — see the field javadoc) — a hit skips both {@link #collectPartitionNames} (which is itself
+ * already CACHED by {@code CachingHmsClient}) and the per-name value-parse/derive loop below. The cache is
+ * BYPASSED (compute directly, never populated) when {@code partitionViewCache} is {@code null} (the
+ * convenience/test ctors) or {@code filter} is present (not the pruning path, and not keyed by filter).
*/
@Override
public List listPartitions(ConnectorSession session,
@@ -1142,6 +1180,16 @@ public List listPartitions(ConnectorSession session,
return siblingMetadata(session, handle).listPartitions(session, handle, filter);
}
HiveTableHandle hiveHandle = (HiveTableHandle) handle;
+ if (partitionViewCache == null || filter.isPresent()) {
+ return listPartitionsUncached(hiveHandle);
+ }
+ PartitionViewCacheKey key = new PartitionViewCacheKey(
+ hiveHandle.getDbName(), hiveHandle.getTableName(), -1L, -1L);
+ return partitionViewCache.get(key, () -> listPartitionsUncached(hiveHandle));
+ }
+
+ /** The derivation seam PERF-06 cache A wraps: builds the full partition-view list, uncached. */
+ private List listPartitionsUncached(HiveTableHandle hiveHandle) {
List partKeyNames = hiveHandle.getPartitionKeyNames();
// Query partition pruning: CACHED listing (use_meta_cache contract; legacy pruned off HiveExternalMetaCache).
List partitionNames = collectPartitionNames(hiveHandle, false);
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
new file mode 100644
index 00000000000000..3b375114572190
--- /dev/null
+++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataPartitionViewCacheTest.java
@@ -0,0 +1,251 @@
+// 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.hive;
+
+import org.apache.doris.connector.api.ConnectorPartitionInfo;
+import org.apache.doris.connector.api.ConnectorType;
+import org.apache.doris.connector.api.pushdown.ConnectorColumnRef;
+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.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.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.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * 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
+ * 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.
+ *
+ * Uses a {@link CountingHmsClient} double (no Mockito, no docker): the derivation seam
+ * {@link HiveConnectorMetadata#listPartitions} wraps is {@code collectPartitionNames} ->
+ * {@code hmsClient.listPartitionNames}, so a cache HIT skips the whole loader (including the per-name
+ * {@link HiveWriteUtils#toPartitionValues} parse + {@link ConnectorPartitionInfo} construction) and the call
+ * count on {@code listPartitionNames} is the enumeration counter — the same seam
+ * {@link HiveConnectorMetadataPartitionListTest} already asserts seam-touch on.
+ *
+ *
Hive is snapshot-less (its {@code beginQuerySnapshot} always pins {@code -1}) and its
+ * {@link HiveTableHandle} carries no schema version, so cache A's key is always
+ * {@code (db, table, -1, -1)} for a given table — unlike iceberg/paimon there is no "distinct snapshot"
+ * dimension to re-key on; only the explicit invalidation hooks (+ TTL) can force a re-derive.
+ */
+public class HiveConnectorMetadataPartitionViewCacheTest {
+
+ private static final List PART_KEYS = Arrays.asList("year", "month");
+ private static final List PARTITIONS = Arrays.asList(
+ "year=2023/month=12",
+ "year=2024/month=01");
+
+ private static HiveConnectorMetadata metadataWithCache(CountingHmsClient client,
+ ConnectorPartitionViewCache> cache) {
+ return new HiveConnectorMetadata(client, Collections.emptyMap(), new FakeConnectorContext(),
+ () -> {
+ throw new UnsupportedOperationException();
+ },
+ () -> {
+ throw new UnsupportedOperationException();
+ },
+ handle -> {
+ throw new UnsupportedOperationException();
+ },
+ new HiveFileListingCache(Collections.emptyMap()), cache);
+ }
+
+ private static ConnectorPartitionViewCache> partitionViewCache() {
+ return new ConnectorPartitionViewCache<>("hive", Collections.emptyMap());
+ }
+
+ private static HiveTableHandle handle() {
+ return new HiveTableHandle.Builder("db1", "t1", HiveTableType.HIVE)
+ .partitionKeyNames(PART_KEYS)
+ .build();
+ }
+
+ private static List names(List infos) {
+ return infos.stream().map(ConnectorPartitionInfo::getPartitionName).collect(Collectors.toList());
+ }
+
+ @Test
+ public void listPartitionsCachesDerivedListAcrossQueries() {
+ // WHY: cache A must memoize the BUILT List keyed by (db, table, -1, -1), so a
+ // repeated query on the same table skips the derived rebuild AND the hmsClient.listPartitionNames
+ // round-trip. MUTATION: not consulting the cache (compute directly every call) -> the seam runs twice
+ // -> red.
+ CountingHmsClient client = new CountingHmsClient(PARTITIONS);
+ ConnectorPartitionViewCache> cache = partitionViewCache();
+ HiveConnectorMetadata md = metadataWithCache(client, cache);
+ HiveTableHandle h = handle();
+
+ List first = md.listPartitions(null, h, Optional.empty());
+ List second = md.listPartitions(null, h, Optional.empty());
+
+ Assertions.assertEquals(PARTITIONS, names(first));
+ Assertions.assertEquals(names(first), names(second), "the cached list is returned verbatim");
+ Assertions.assertEquals(1, client.listPartitionNamesCalls,
+ "a cache hit must not re-enumerate (listPartitionNames once)");
+ }
+
+ @Test
+ public void listPartitionsInvalidateTableForcesReEnumeration() {
+ // WHY: REFRESH TABLE (HiveConnector.invalidateTable -> cache.invalidateTable) must drop the cached list
+ // 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();
+ HiveConnectorMetadata md = metadataWithCache(client, cache);
+ HiveTableHandle h = handle();
+
+ md.listPartitions(null, h, Optional.empty());
+ cache.invalidateTable("db1", "t1");
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(2, client.listPartitionNamesCalls, "invalidateTable must force a re-enumeration");
+ }
+
+ @Test
+ 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();
+ HiveConnectorMetadata md = metadataWithCache(client, cache);
+ HiveTableHandle h = handle();
+
+ md.listPartitions(null, h, Optional.empty());
+ cache.invalidateAll();
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(2, client.listPartitionNamesCalls, "invalidateAll must force a re-enumeration");
+ }
+
+ @Test
+ public void listPartitionsWithFilterBypassesCache() {
+ // WHY: a present filter must BYPASS the cache and compute directly -- and must NOT populate it either, so
+ // a later empty-filter (pruning) call still misses. Hive already ignores the filter value entirely
+ // (returns the full set regardless), but the cache-A key carries no filter dimension, so caching a
+ // filtered call's result would be indistinguishable from caching an unfiltered one; bypassing keeps the
+ // two paths independent, mirroring the iceberg/paimon pattern. MUTATION: caching regardless of filter ->
+ // 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();
+ HiveConnectorMetadata md = metadataWithCache(client, cache);
+ HiveTableHandle h = handle();
+
+ ConnectorExpression filter = new ConnectorComparison(ConnectorComparison.Operator.EQ,
+ new ConnectorColumnRef("year", ConnectorType.of("STRING")),
+ new ConnectorLiteral(ConnectorType.of("STRING"), "2024"));
+ md.listPartitions(null, h, Optional.of(filter));
+ md.listPartitions(null, h, Optional.of(filter));
+ Assertions.assertEquals(2, client.listPartitionNamesCalls,
+ "a present filter must bypass the cache (compute every call)");
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(3, client.listPartitionNamesCalls,
+ "the empty-filter call must miss (filtered calls never populate)");
+ }
+
+ @Test
+ public void listPartitionsNullCacheEnumeratesEveryCall() {
+ // The convenience/test-ctor analogue: a null cache means compute directly every call -> the seam runs on
+ // every query (no cross-query sharing). MUTATION: defaulting to a shared cache when null was intended ->
+ // listPartitionNamesCalls stays 1 -> red.
+ CountingHmsClient client = new CountingHmsClient(PARTITIONS);
+ HiveConnectorMetadata md = metadataWithCache(client, null);
+ HiveTableHandle h = handle();
+
+ md.listPartitions(null, h, Optional.empty());
+ md.listPartitions(null, h, Optional.empty());
+ Assertions.assertEquals(2, client.listPartitionNamesCalls, "a null (disabled) cache must re-enumerate every call");
+ }
+
+ /**
+ * Minimal {@link HmsClient} double: {@code listPartitionNames} returns a fixed list and counts calls;
+ * {@code getPartitions} fails loud (the per-partition round-trip {@link HiveConnectorMetadata#listPartitions}
+ * must never make — mirrors {@link HiveConnectorMetadataPartitionListTest}'s FakeHmsClient).
+ */
+ private static final class CountingHmsClient implements HmsClient {
+ private final List partitionNames;
+ int listPartitionNamesCalls;
+
+ CountingHmsClient(List partitionNames) {
+ this.partitionNames = partitionNames;
+ }
+
+ @Override
+ public List listPartitionNames(String dbName, String tableName, int maxParts) {
+ listPartitionNamesCalls++;
+ return partitionNames;
+ }
+
+ @Override
+ public List getPartitions(String dbName, String tableName, List partNames) {
+ throw new AssertionError("get_partitions_by_names must not be called by partition listing");
+ }
+
+ @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 HmsPartitionInfo getPartition(String dbName, String tableName, List values) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+}
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
new file mode 100644
index 00000000000000..57c765f9482e90
--- /dev/null
+++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPartitionViewCacheTest.java
@@ -0,0 +1,144 @@
+// 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.hive;
+
+import org.apache.doris.connector.api.ConnectorPartitionInfo;
+import org.apache.doris.connector.cache.ConnectorPartitionViewCache;
+import org.apache.doris.connector.cache.PartitionViewCacheKey;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * PERF-06 (S6) tests for {@link HiveConnector}'s partition-view cache A wiring: that the cache is ALWAYS built
+ * (no session=user gate — hive has no per-query-identity cache-disabling convention; its caches, like
+ * {@link HiveFileListingCache}, are already built unconditionally per-catalog) and that the REFRESH hooks +
+ * {@code invalidatePartition} route to it.
+ */
+public class HiveConnectorPartitionViewCacheTest {
+
+ private static Map props() {
+ Map m = new HashMap<>();
+ m.put("hive.metastore.uris", "thrift://host:9083");
+ return m;
+ }
+
+ @Test
+ public void partitionViewCacheAlwaysBuilt() {
+ // Unlike iceberg, hive has NO session=user / per-user credential-isolation cache-disabling convention (a
+ // hive catalog authenticates at catalog-creation time, not per-query session identity), so the connector
+ // must construct cache A unconditionally. MUTATION: a stray session-like gate leaving the field null on
+ // some property combination -> red.
+ HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext());
+ Assertions.assertNotNull(connector.partitionViewCacheForTest(),
+ "a fresh hive connector must always build the partition-view cache");
+ }
+
+ @Test
+ public void refreshHooksInvalidatePartitionViewCache() {
+ // The REFRESH hooks must clear cache A too (else external DDL/writes stay invisible beyond the TTL):
+ // REFRESH TABLE drops that table's entry, REFRESH DATABASE that db's, REFRESH CATALOG everything.
+ // Asserted via a counting loader (the framework's size() is package-private): after 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.
+ HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext());
+ ConnectorPartitionViewCache> 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);
+
+ // 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).
+ cache.get(db1t1, loader);
+ cache.get(db1t1, loader);
+ Assertions.assertEquals(1, loads[0], "second get is a hit");
+ connector.invalidateTable("db1", "t1");
+ cache.get(db1t1, loader);
+ Assertions.assertEquals(2, loads[0], "REFRESH TABLE forces a reload of db1.t1");
+
+ // REFRESH DATABASE db1 -> db1.t2 re-loads; db2.t1 unaffected.
+ cache.get(db1t2, loader); // loads=3 (miss)
+ cache.get(db2t1, loader); // loads=4 (miss)
+ cache.get(db1t2, loader); // hit
+ cache.get(db2t1, loader); // hit
+ Assertions.assertEquals(4, loads[0]);
+ connector.invalidateDb("db1");
+ cache.get(db2t1, loader); // db2 untouched -> hit
+ Assertions.assertEquals(4, loads[0], "REFRESH DATABASE db1 must NOT drop db2's entries");
+ cache.get(db1t2, loader); // db1.t2 dropped -> miss
+ Assertions.assertEquals(5, loads[0], "REFRESH DATABASE db1 drops db1's entries");
+
+ // REFRESH CATALOG -> everything re-loads.
+ connector.invalidateAll();
+ cache.get(db2t1, loader);
+ Assertions.assertEquals(6, loads[0], "REFRESH CATALOG drops everything");
+ }
+
+ @Test
+ public void invalidatePartitionDropsTheWholeTablesCachedView() {
+ // Cache A's key carries no partition-name axis (only db/table/-1/-1), so a partition add/drop/alter
+ // (HiveConnector.invalidatePartition) cannot be scoped finer than the table's single cached entry --
+ // 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();
+ 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);
+
+ cache.get(db1t1, loader);
+ cache.get(db1t2, loader);
+ Assertions.assertEquals(2, loads[0]);
+
+ connector.invalidatePartition("db1", "t1", Collections.singletonList("dt=2024-01-01"));
+
+ cache.get(db1t1, loader);
+ Assertions.assertEquals(3, loads[0], "invalidatePartition must drop the whole cached view of that table");
+ cache.get(db1t2, loader);
+ Assertions.assertEquals(3, loads[0], "invalidatePartition must NOT drop another table's cached view");
+ }
+
+ @Test
+ public void publicHooksAreNoThrowWithoutBuildingAClient() {
+ // A fresh connector never built its metastore client (hmsClient == null). The public REFRESH hooks must
+ // not force-build one just to route to the view cache, and must not throw.
+ HiveConnector connector = new HiveConnector(props(), new FakeConnectorContext());
+ Assertions.assertDoesNotThrow(() -> connector.invalidateTable("db", "t"));
+ Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db"));
+ Assertions.assertDoesNotThrow(() -> connector.invalidateAll());
+ Assertions.assertDoesNotThrow(() -> {
+ connector.invalidatePartition("db", "t", Collections.singletonList("dt=2024-01-01"));
+ });
+ }
+}
From 2efa9f070d8f270d37eb3ab8e4e9e7ee8a6e8209 Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 04:46:48 +0800
Subject: [PATCH 16/19] [fix](catalog) make Cache B version the frozen
partition name-set for all engines + drop Cache B on db/catalog invalidation
FIX 1 (correctness): PluginDrivenMvccExternalTable.getPartitionMetaVersion now
unconditionally returns the frozen partition NAME-SET
(new HashSet<>(getOriginPartitions(scan).keySet())); the O(1)
"@" branch is removed. That token is only sound where a
connector's listPartitions content is a pure function of the snapshot id, which
is NOT true for iceberg NON-RANGE tables (identity/bucket/truncate/multi-field):
their nameToPartitionItem (Cache A, keyed (-1,-1)) and the snapshot-id token
(IcebergLatestSnapshotCache) expire independently, so a snapshot-id token could
serve SortedPartitionRanges staler than the pin's own partitions -> within-query
disagreement -> silent under-inclusive pruning. Deriving the version from the
frozen name set makes it uniform across all engines (the scheme snapshot-less
hive already used).
FIX 2 (completeness): ExternalMetaCacheMgr.invalidateDb / invalidateCatalog /
removeCatalog now also drop the NereidsSortedPartitionsCacheManager (Cache B),
mirroring invalidateTable. The manager has no db/catalog-scoped eviction key, so
a null-safe helper calls invalidateAll() (coarse but correct; lazy rebuild).
Tests: retarget the token-string assertion to the name-set; add a real-snapshot
(snapshotId=42 held constant) rebuild/hit test proving the iceberg non-RANGE
coherence; add db/catalog/removeCatalog Cache-B-drop tests. fe-core only.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_014ZngdXGBAXNDRe7jA42mAS
---
.../datasource/ExternalMetaCacheMgr.java | 27 ++++
.../mvcc/PluginDrivenMvccExternalTable.java | 33 +++--
...tedPartitionsCacheManagerExternalTest.java | 140 ++++++++++++++++--
3 files changed, 175 insertions(+), 25 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java
index ee4017a49eb698..115fb6becb0d0a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java
@@ -20,6 +20,7 @@
import org.apache.doris.catalog.Env;
import org.apache.doris.common.Config;
import org.apache.doris.common.ThreadPoolManager;
+import org.apache.doris.common.cache.NereidsSortedPartitionsCacheManager;
import org.apache.doris.datasource.doris.DorisExternalMetaCache;
import org.apache.doris.datasource.metacache.AbstractExternalMetaCache;
import org.apache.doris.datasource.metacache.ExternalMetaCache;
@@ -184,6 +185,10 @@ public void invalidateCatalog(long catalogId) {
routeCatalogEngines(catalogId, cache -> safeInvalidate(
cache, catalogId, "invalidateCatalog",
() -> cache.invalidateCatalogEntries(catalogId)));
+ // Cache B (Nereids sorted-partition-ranges) has no db/catalog-scoped eviction, so a catalog-level
+ // REFRESH must drop ALL of its entries -- else binary-search pruning could serve ranges older than
+ // the refreshed metadata. Coarse but correct (a rebuild is cheap and lazy). Mirrors invalidateTable.
+ invalidateSortedPartitionsCache();
}
public void invalidateCatalogByEngine(long catalogId, String engine) {
@@ -196,6 +201,9 @@ public void removeCatalog(long catalogId) {
routeCatalogEngines(catalogId, cache -> safeInvalidate(
cache, catalogId, "removeCatalog",
() -> cache.invalidateCatalog(catalogId)));
+ // Drop ALL Cache B entries: the catalog is going away and Cache B has no catalog-scoped eviction.
+ // (invalidateAll needs no catalog name, so this is safe even after the catalog was already removed.)
+ invalidateSortedPartitionsCache();
}
public void removeCatalogByEngine(long catalogId, String engine) {
@@ -207,6 +215,9 @@ public void removeCatalogByEngine(long catalogId, String engine) {
public void invalidateDb(long catalogId, String dbName) {
routeCatalogEngines(catalogId, cache -> safeInvalidate(
cache, catalogId, "invalidateDb", () -> cache.invalidateDb(catalogId, dbName)));
+ // Cache B has no db-scoped eviction key, so a db-level REFRESH drops ALL entries (coarse but
+ // correct -- a rebuild is cheap and lazy). Mirrors invalidateTable's Cache B wiring.
+ invalidateSortedPartitionsCache();
}
public void invalidateTable(long catalogId, String dbName, String tableName) {
@@ -221,6 +232,22 @@ public void invalidateTable(long catalogId, String dbName, String tableName) {
}
}
+ /**
+ * Drops ALL entries of the Nereids sorted-partition-ranges cache (Cache B). Used by the db/catalog-level
+ * invalidations, which have no finer-grained (db/catalog-scoped) eviction key on that cache. Null-safe:
+ * during early startup / checkpoint replay {@code Env.getCurrentEnv()} or its cache manager may be unset.
+ */
+ private void invalidateSortedPartitionsCache() {
+ Env env = Env.getCurrentEnv();
+ if (env == null) {
+ return;
+ }
+ NereidsSortedPartitionsCacheManager mgr = env.getSortedPartitionsCacheManager();
+ if (mgr != null) {
+ mgr.invalidateAll();
+ }
+ }
+
public void invalidateTableByEngine(long catalogId, String engine, String dbName, String tableName) {
routeSpecifiedEngine(engine, cache -> safeInvalidate(
cache, catalogId, "invalidateTableByEngine",
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java
index 2a48a6a6072088..41bd5ad2b618de 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java
@@ -634,17 +634,23 @@ public Map, PartitionItem> getOriginPartitions(CatalogRelation scan) {
@Override
public Object getPartitionMetaVersion(CatalogRelation scan) {
- ConnectorMvccSnapshot cs = getOrMaterialize(pinnedSnapshot(scan)).getConnectorSnapshot();
- if (cs.getSnapshotId() != -1L) {
- // Opaque version token: iceberg/paimon expose an immutable (snapshotId, schemaId) pair.
- return cs.getSnapshotId() + "@" + cs.getSchemaId();
- }
- // Snapshot-less connector (e.g. hive): no real MVCC snapshot id to version against. Derive the
- // token from the frozen partition NAME SET instead -- getOriginPartitions(scan) reads the SAME
- // pin, so version == exact content the ranges are built from: the cache rebuilds precisely when
- // the partition set changes, never on a stale set. A compact copy of just the name strings (NOT
- // the live keySet view), so the cross-query cache entry does not retain the whole pin's
- // partition-item map.
+ // The version token MUST equal the EXACT partition content the ranges are built from
+ // (getOriginPartitions reads the SAME pin), so Cache B can never disagree with the pin's own
+ // nameToPartitionItem regardless of how/when a connector's listPartitions cache (Cache A)
+ // refreshes: the cache rebuilds precisely when the partition set changes, never on a stale set.
+ // A compact copy of just the name strings (NOT the live keySet view), so the cross-query cache
+ // entry does not retain the whole pin's partition-item map.
+ //
+ // The former "@" O(1) token was REMOVED: it is only sound where a
+ // connector's listPartitions content is a pure function of the snapshot id, which is NOT true for
+ // iceberg NON-RANGE tables (identity / bucket / truncate / multi-field partitioning). There the
+ // pin's nameToPartitionItem is served by listPartitions (Cache A, keyed (-1,-1), enumerated at
+ // currentSnapshot with its own TTL, ignoring the pin) while the snapshot-id token comes from a
+ // DIFFERENT cache (IcebergLatestSnapshotCache) with its own TTL; the two expire independently, so
+ // a snapshot-id token could serve SortedPartitionRanges STALER than the pin's own partitions ->
+ // within-query disagreement -> silent under-inclusive pruning. Deriving the version from the
+ // frozen name set makes it uniform across ALL engines -- the same rigorous scheme snapshot-less
+ // hive already relied on -- at the cost of an O(partitions) set copy per lookup.
return new HashSet<>(getOriginPartitions(scan).keySet());
}
@@ -657,8 +663,9 @@ public long getPartitionMetaLoadTimeMillis(CatalogRelation scan) {
// getSortedPartitionRanges is now a pure pass-through to ExternalTable's cache-manager delegation
// (5c17b748880's snapshotId==-1 short-circuit was removed: getPartitionMetaVersion above derives a
- // content-comparable version for snapshot-less connectors, so Cache B is now correct for them too) --
- // no override needed here.
+ // content-comparable version from the frozen partition name set for ALL engines, so Cache B is
+ // correct uniformly -- snapshot-less hive and real-snapshot iceberg/paimon alike) -- no override
+ // needed here.
private Optional pinnedSnapshot(CatalogRelation scan) {
if (scan instanceof LogicalFileScan) {
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
index 9cfb03ae14d2d0..7e810f54a00c9d 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
@@ -86,6 +86,7 @@ public class NereidsSortedPartitionsCacheManagerExternalTest {
private static final String CTL = "ctl";
private static final String DB = "db";
private static final String TBL = "t";
+ private static final long CATALOG_ID = 7L;
@AfterEach
public void tearDown() {
@@ -289,24 +290,28 @@ public void testPluginDrivenMvccExternalTableRealOriginPartitionsAndVersion() th
Assertions.assertEquals(pinnedPartsT1, origin,
"getOriginPartitions must dispatch through the LogicalFileScan branch of pinnedSnapshot "
+ "and return exactly the t1 pin's partition map (not t2's, not latest's)");
- Assertions.assertEquals("42@7", version,
- "getPartitionMetaVersion must return @ from the t1 pin's connector "
- + "snapshot, dispatched via the same LogicalFileScan branch");
+ Assertions.assertEquals(new java.util.HashSet<>(pinnedPartsT1.keySet()), version,
+ "getPartitionMetaVersion must return the FROZEN partition NAME-SET of the t1 pin "
+ + "(a real snapshotId=42 no longer yields the @ token) -- "
+ + "resolved via the same LogicalFileScan branch, so it is t1's name set, not t2's "
+ + "(empty) or latest's");
} finally {
ConnectContext.remove();
}
}
- // ──────────────────── Hive sentinel snapshot (-1) now uses Cache B via a NAME-SET version ─────
+ // ──────────────────── Cache B version is the frozen partition NAME-SET for ALL engines ─────
//
- // getPartitionMetaVersion's token is "@" for a REAL snapshot id, but hive's
- // beginQuerySnapshot always pins the sentinel snapshotId == -1 (hive has no MVCC snapshot), so that
- // token would be a CONSTANT across queries. Instead, for a snapshot-less pin the version is derived
- // from the frozen partition NAME SET (getOriginPartitions(scan).keySet(), copied) -- the SAME map the
- // ranges are built from, so version == exact content and the cache rebuilds precisely when the
- // partition set changes. PluginDrivenMvccExternalTable no longer overrides getSortedPartitionRanges
- // (the -1 short-circuit added in 5c17b748880 was removed): a snapshot-less pin now goes through the
- // inherited ExternalTable#getSortedPartitionRanges -> the cache manager, exactly like iceberg/paimon.
+ // getPartitionMetaVersion always returns the frozen partition NAME SET
+ // (getOriginPartitions(scan).keySet(), copied) -- the SAME map the ranges are built from, so
+ // version == exact content and Cache B rebuilds precisely when the partition set changes, never on a
+ // stale set. This is uniform across hive (snapshotId == -1 sentinel), paimon and iceberg: the old
+ // "@" O(1) token was removed because it is unsafe wherever a connector's
+ // listPartitions content is not a pure function of the snapshot id (iceberg non-RANGE: identity /
+ // bucket / truncate / multi-field partitioning), whose nameToPartitionItem (Cache A) and snapshot-id
+ // token (a DIFFERENT cache) expire independently. PluginDrivenMvccExternalTable no longer overrides
+ // getSortedPartitionRanges (the -1 short-circuit added in 5c17b748880 was removed): every pin goes
+ // through the inherited ExternalTable#getSortedPartitionRanges -> the cache manager.
/**
* Builds a live Env whose {@code getSortedPartitionsCacheManager()} returns {@code rangesCacheMgr},
@@ -356,6 +361,18 @@ private static PluginDrivenMvccSnapshot sentinelPin(Map p
return new PluginDrivenMvccSnapshot(sentinelSnapshot, parts, Maps.newHashMap());
}
+ /**
+ * A pin carrying a REAL (non-sentinel) connector snapshot id -- e.g. iceberg. Used to prove Cache B's
+ * version token is the frozen partition NAME-SET even for {@code snapshotId != -1}: the snapshot id is
+ * held CONSTANT across queries while the partition set changes, exactly the iceberg non-RANGE hazard
+ * where Cache A (listPartitions) and the snapshot-id token expire independently.
+ */
+ private static PluginDrivenMvccSnapshot realSnapshotPin(Map parts) {
+ ConnectorMvccSnapshot realSnapshot = ConnectorMvccSnapshot.builder()
+ .snapshotId(42L).schemaId(7L).build();
+ return new PluginDrivenMvccSnapshot(realSnapshot, parts, Maps.newHashMap());
+ }
+
@Test
public void testGetSortedPartitionRangesPresentForSnapshotLessNonEmptyPartitions() throws Exception {
Map pinnedParts = Maps.newHashMap();
@@ -410,6 +427,49 @@ public void testGetSortedPartitionRangesRebuildsWhenSnapshotLessNameSetChanges()
}
}
+ @Test
+ public void testGetSortedPartitionRangesRebuildsWhenRealSnapshotNameSetChanges() throws Exception {
+ // Real-snapshot (snapshotId != -1) coherence: the version token is the frozen partition NAME-SET
+ // for ALL engines, not just hive. This is the iceberg non-RANGE hazard -- the pin's
+ // nameToPartitionItem (served by listPartitions / Cache A) can advance while the connector snapshot
+ // id stays CONSTANT, because the two are backed by INDEPENDENTLY-expiring caches. Under the removed
+ // "@" token both queries would key "42@7" and serve a STALE HIT (silent
+ // under-inclusive pruning); under the name-set token the grown partition set forces a rebuild.
+ PluginDrivenMvccExternalTable table = newRealWiringTable();
+ NereidsSortedPartitionsCacheManager rangesCacheMgr = new NereidsSortedPartitionsCacheManager();
+ try (MockedStatic envStatic = mockEnvWithRangesCacheManager(rangesCacheMgr)) {
+ Map partsAtQuery1 = Maps.newHashMap();
+ partsAtQuery1.put("id=1", listItem(1));
+ partsAtQuery1.put("id=2", listItem(2));
+ LogicalFileScan scan1 = pinLatestAndBuildScan(table, realSnapshotPin(partsAtQuery1));
+ SortedPartitionRanges first = table.getSortedPartitionRanges(scan1).orElse(null);
+ Assertions.assertNotNull(first, "ranges built and cached at the first (2-partition) name set");
+ Assertions.assertEquals(2, first.sortedPartitions.size());
+
+ // Same query again: IDENTICAL name set at the SAME real snapshot id => cache HIT (same instance).
+ LogicalFileScan scanHit = pinLatestAndBuildScan(table, realSnapshotPin(partsAtQuery1));
+ SortedPartitionRanges hit = table.getSortedPartitionRanges(scanHit).orElse(null);
+ Assertions.assertSame(first, hit,
+ "unchanged name set at a real snapshotId=42 => cache HIT returns the SAME instance");
+
+ // A later query whose Cache A refreshed to include a new partition while the connector snapshot
+ // id is STILL 42: the name set grew, so Cache B must rebuild even though "42@7" is unchanged.
+ Map partsAtQuery2 = Maps.newHashMap();
+ partsAtQuery2.put("id=1", listItem(1));
+ partsAtQuery2.put("id=2", listItem(2));
+ partsAtQuery2.put("id=3", listItem(3));
+ LogicalFileScan scan2 = pinLatestAndBuildScan(table, realSnapshotPin(partsAtQuery2));
+ SortedPartitionRanges rebuilt = table.getSortedPartitionRanges(scan2).orElse(null);
+ Assertions.assertNotNull(rebuilt, "ranges rebuilt at the second (3-partition) name set");
+ Assertions.assertNotSame(first, rebuilt,
+ "name-set change at a CONSTANT real snapshotId=42 must still trigger a rebuild "
+ + "(the removed @ token would have served a stale hit)");
+ Assertions.assertEquals(3, rebuilt.sortedPartitions.size(), "rebuilt from the new partition set");
+ } finally {
+ ConnectContext.remove();
+ }
+ }
+
@Test
public void testGetSortedPartitionRangesEmptyForSnapshotLessEmptyPartitions() throws Exception {
PluginDrivenMvccExternalTable table = newRealWiringTable();
@@ -462,4 +522,60 @@ public void testExternalMetaCacheMgrInvalidateTableDropsRangesCacheEntry() throw
"ExternalMetaCacheMgr.invalidateTable must also drop the "
+ "NereidsSortedPartitionsCacheManager entry (ExternalMetaCacheMgr.java:217-220)");
}
+
+ // ── db/catalog-level invalidation also drops Cache B (§10 completeness) ──
+ //
+ // invalidateTable (above) already drops the ranges cache; invalidateDb / invalidateCatalog /
+ // removeCatalog previously did NOT, so a db- or catalog-level REFRESH left STALE Cache B entries.
+ // Cache B has no db/catalog-scoped eviction key, so those coarse invalidations drop ALL entries
+ // (invalidateAll). Each test drives the REAL ExternalMetaCacheMgr method end-to-end.
+
+ /**
+ * Populates a live {@link NereidsSortedPartitionsCacheManager} with one entry, then runs {@code
+ * invalidation} against a REAL {@link ExternalMetaCacheMgr} (with a mocked {@code Env} wiring
+ * {@code getCatalogMgr}/{@code getSortedPartitionsCacheManager}) and asserts Cache B is emptied.
+ */
+ private void assertDropsAllRangesCache(java.util.function.Consumer invalidation)
+ throws Exception {
+ newLiveConnectContext();
+ NereidsSortedPartitionsCacheManager rangesCacheMgr = new NereidsSortedPartitionsCacheManager();
+ FakeExternalTable t = new FakeExternalTable(true);
+ t.parts.put("id=1", listItem(1));
+ Assertions.assertTrue(rangesCacheMgr.get(t.table, (CatalogRelation) null).isPresent(),
+ "ranges built and cached before invalidation");
+ Assertions.assertEquals(1, rangesCacheMgr.getPartitionCaches().estimatedSize(),
+ "one entry cached before invalidation");
+
+ Env env = Mockito.mock(Env.class);
+ CatalogMgr catalogMgr = Mockito.mock(CatalogMgr.class);
+ CatalogIf catalog = Mockito.mock(CatalogIf.class);
+ Mockito.when(catalog.getName()).thenReturn(CTL);
+ Mockito.when(catalogMgr.getCatalog(CATALOG_ID)).thenReturn(catalog);
+ Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
+ Mockito.when(env.getSortedPartitionsCacheManager()).thenReturn(rangesCacheMgr);
+
+ ExternalMetaCacheMgr metaCacheMgr = new ExternalMetaCacheMgr(true);
+ try (MockedStatic envStatic = Mockito.mockStatic(Env.class)) {
+ envStatic.when(Env::getCurrentEnv).thenReturn(env);
+ invalidation.accept(metaCacheMgr);
+ }
+
+ Assertions.assertEquals(0, rangesCacheMgr.getPartitionCaches().estimatedSize(),
+ "db/catalog-level invalidation must also drop the NereidsSortedPartitionsCacheManager entries");
+ }
+
+ @Test
+ public void testInvalidateDbDropsRangesCache() throws Exception {
+ assertDropsAllRangesCache(m -> m.invalidateDb(CATALOG_ID, DB));
+ }
+
+ @Test
+ public void testInvalidateCatalogDropsRangesCache() throws Exception {
+ assertDropsAllRangesCache(m -> m.invalidateCatalog(CATALOG_ID));
+ }
+
+ @Test
+ public void testRemoveCatalogDropsRangesCache() throws Exception {
+ assertDropsAllRangesCache(m -> m.removeCatalog(CATALOG_ID));
+ }
}
From 6c1506428260efff9f26e581748ae8287bdc8a46 Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 10:23:07 +0800
Subject: [PATCH 17/19] [test](catalog) stub getStatementScope in
PhysicalIcebergMergeSinkTest connector mock
The per-statement metadata funnel (777a61671ab) routed the merge sink's
connector partition-field branch through PluginDrivenMetadata.get, which
dereferences session.getStatementScope() before memoizing getMetadata. That
commit did not update PhysicalIcebergMergeSinkTest, whose pluginTable helper
mocks ConnectorSession without stubbing getStatementScope(), so the two tests
that reach the connector branch (postFlipMergeBuildsDistributionFromConnectorSpec,
postFlipAbsentTableHandleDegradesToUnpartitioned) hit a null scope and NPE'd.
Stub the mock session to return ConnectorStatementScope.NONE, matching the
established convention across the plugin-driven FE tests. NONE runs the loader
on every call (byte-identical to a direct getMetadata), so the connector branch
proceeds to the mocked handle/provider/spec without changing what is asserted.
All 9 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SMtYwYyyubZZiC1odLZTG3
---
.../trees/plans/physical/PhysicalIcebergMergeSinkTest.java | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java
index 9aa92915a96e13..68ed38f3df2b07 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSinkTest.java
@@ -23,6 +23,7 @@
import org.apache.doris.connector.api.Connector;
import org.apache.doris.connector.api.ConnectorMetadata;
import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorStatementScope;
import org.apache.doris.connector.api.handle.ConnectorTableHandle;
import org.apache.doris.connector.api.write.ConnectorWritePartitionField;
import org.apache.doris.connector.api.write.ConnectorWritePartitionSpec;
@@ -313,6 +314,9 @@ private static PluginDrivenExternalTable pluginTable(ConnectorWritePartitionSpec
ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class);
ConnectorSession session = Mockito.mock(ConnectorSession.class);
+ // The sink resolves metadata through PluginDrivenMetadata.get, which memoizes on the session's
+ // per-statement scope; NONE runs the loader every call (byte-identical to a direct getMetadata).
+ Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE);
Connector connector = Mockito.mock(Connector.class);
Mockito.when(connector.getWritePlanProvider()).thenReturn(provider);
// Production selects the write provider per-handle; a plain mock does not run the interface default.
From 1176d0f568fce5d5beb9fd6a425bdc0840f8c811 Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 10:37:26 +0800
Subject: [PATCH 18/19] [fix](ci) allowlist gitleaks generic-api-key false
positive on "token: iceberg/paimon"
The Gitleaks PR check failed with "leaks found: 3", all of them the
generic-api-key rule firing on plain prose. In code comments and a
plan-doc the phrase "version token: iceberg/paimon ..." appears; the
rule reads "token: " as key=secret, and the value "iceberg/paimon"
has Shannon entropy 3.52, just above gitleaks' 3.5 cutoff, so it is
reported as a generic API key. It is a cache version-token discussion,
not a credential.
Gitleaks scans each commit's diff over the PR range
(git . --log-opts="--no-merges BASE..HEAD"), so rewording only the branch
tip does not help: the commits that introduced the phrase remain in range
and keep triggering. Allowlisting the exact benign match is the correct,
history-robust fix and matches the existing false-positive allowlists in
this config.
Verified with the pinned gitleaks 8.30.0 over the full PR commit range:
findings drop from 3 to 0.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SMtYwYyyubZZiC1odLZTG3
---
.gitleaks.toml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 8fb5f5626121aa..c01003b5a6c548 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -6,6 +6,12 @@ useDefault = true
[[rules]]
id = "generic-api-key"
+[[rules.allowlists]]
+description = "Ignore prose 'version token: iceberg/paimon' in code comments/plan-doc — a cache version-token discussion, not a credential (generic-api-key false positive: 'iceberg/paimon' entropy 3.52 barely clears the 3.5 threshold)"
+condition = "AND"
+regexTarget = "match"
+regexes = ['''token:\s*iceberg/paimon''']
+
[[rules.allowlists]]
description = "Ignore the fake OIDC token fixture in AuthenticatorManagerTest"
condition = "AND"
From b20ffb3bd5ce94359730df8b82c386b59a1dea82 Mon Sep 17 00:00:00 2001
From: morningman
Date: Tue, 21 Jul 2026 11:53:02 +0800
Subject: [PATCH 19/19] [fix](catalog) remove superseded file-scoped parquet
position deletes on iceberg v3 DELETE
readExistingFileScopedDeletes gated candidates with the bounds-aware
ContentFileUtil.isFileScoped() but then matched them with the raw
DeleteFile.referencedDataFile() (manifest field referenced_data_file, id
143). That field is set only by the v3 puffin DV writer (BaseDVFileWriter);
a Spark-written file-scoped parquet/orc position delete leaves it null and
carries its referenced data file in the file_path column bounds instead
(Spark's default write.delete.granularity is FILE). Such a delete therefore
passed isFileScoped() yet was dropped by the null check, so a v3 DELETE on a
v2->v3 upgraded table wrote the new DV (unioning the old positions) but never
removeDeletes'd the superseded parquet delete -- leaving a stale parquet
delete file live (RowDelta removedDeleteFiles=null). Switch line 1053 to the
bounds-aware ContentFileUtil.referencedDataFileLocation(), consistent with the
isFileScoped() gate; DV / partition-scoped / equality-delete handling is
unchanged.
Also align the v3 row-lineage INSERT-rejection assertion in
test_iceberg_v3_row_lineage_complex_query with the connector-agnostic SPI
wording ("invisible column", from #64688), replacing the retired
iceberg-specific "row lineage column" message.
Fixes the two external_table_p0 iceberg regressions in
Doris_External_Regression #1001567:
test_iceberg_v3_row_lineage_continuous_dml (puffin-vs-parquet delete layout)
and test_iceberg_v3_row_lineage_complex_query (stale exception message).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01SMtYwYyyubZZiC1odLZTG3
---
.../connector/iceberg/IcebergConnectorTransaction.java | 8 +++++++-
.../test_iceberg_v3_row_lineage_complex_query.groovy | 4 ++--
2 files changed, 9 insertions(+), 3 deletions(-)
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 b850f84c63fdc4..14502c4ff077e3 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
@@ -1050,7 +1050,13 @@ private List readExistingFileScopedDeletes(
|| !ContentFileUtil.isFileScoped(deleteFile)) {
continue;
}
- String referenced = deleteFile.referencedDataFile();
+ // Use the bounds-aware ContentFileUtil accessor, consistent with the isFileScoped() gate
+ // above: a Spark-written file-scoped parquet/orc position delete carries its referenced
+ // data file in the file_path column bounds, NOT in the manifest referenced_data_file field
+ // (id 143) — only a v3 puffin DV sets that field. The raw DeleteFile.referencedDataFile()
+ // therefore returns null for such deletes, so they would pass isFileScoped() yet be dropped
+ // here, leaving the superseded parquet delete live after a v3 DELETE.
+ String referenced = ContentFileUtil.referencedDataFileLocation(deleteFile);
if (referenced == null || !touchedDataFilePaths.contains(referenced)) {
continue;
}
diff --git a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_complex_query.groovy b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_complex_query.groovy
index 95de1c4ea1a464..62cbf86497fad0 100644
--- a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_complex_query.groovy
+++ b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_complex_query.groovy
@@ -195,7 +195,7 @@ suite("test_iceberg_v3_row_lineage_complex_query", "p0,external,iceberg,external
select rid, id, tag, 0, date '2024-01-01'
from ${dimTable}
"""
- exception "Cannot specify row lineage column '_row_id' in INSERT statement"
+ exception "Cannot specify invisible column '_row_id' in INSERT statement"
}
test {
@@ -204,7 +204,7 @@ suite("test_iceberg_v3_row_lineage_complex_query", "p0,external,iceberg,external
select seq, id, tag, 0, date '2024-01-01'
from ${dimTable}
"""
- exception "Cannot specify row lineage column '_last_updated_sequence_number' in INSERT statement"
+ exception "Cannot specify invisible column '_last_updated_sequence_number' in INSERT statement"
}
} finally {
sql """drop table if exists ${dimTable}"""