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" 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"); + } +} 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")); + }); + } +} 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/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/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"); + } +} 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"); + } +} 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 e1ff655002044e..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,12 +215,37 @@ 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) { routeCatalogEngines(catalogId, cache -> safeInvalidate( cache, catalogId, "invalidateTable", () -> cache.invalidateTable(catalogId, dbName, tableName))); + // Also drop the Nereids sorted-partition-ranges cache for this external table so binary-search + // pruning does not serve ranges older than the refreshed metadata. + CatalogIf ctl = getCatalog(catalogId); + if (ctl != null) { + Env.getCurrentEnv().getSortedPartitionsCacheManager().invalidateTable(ctl.getName(), dbName, 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) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java index ade57ee4262db6..94010de31d700f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java @@ -21,11 +21,13 @@ import org.apache.doris.catalog.DatabaseIf; import org.apache.doris.catalog.Env; import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.SupportBinarySearchFilteringPartitions; import org.apache.doris.catalog.TableAttributes; import org.apache.doris.catalog.TableIf; import org.apache.doris.catalog.TableIndexes; import org.apache.doris.common.AnalysisException; import org.apache.doris.common.Pair; +import org.apache.doris.common.cache.NereidsSortedPartitionsCacheManager; import org.apache.doris.common.io.Text; import org.apache.doris.common.io.Writable; import org.apache.doris.common.util.PropertyAnalyzer; @@ -517,15 +519,22 @@ public boolean supportInternalPartitionPruned() { } /** - * Get sorted partition ranges for binary search filtering. - * Subclasses can override this method to provide sorted partition ranges - * for efficient partition pruning. + * Cross-query cache of the pre-built {@link SortedPartitionRanges} for binary-search partition + * pruning. Tables that implement {@link SupportBinarySearchFilteringPartitions} (external MVCC: + * iceberg/paimon) route through the shared {@link NereidsSortedPartitionsCacheManager}, keyed by the + * pinned connector snapshot; others return empty (the caller falls back to building ranges fresh). * * @param scan the catalog relation * @return sorted partition ranges, or empty if not supported */ + @SuppressWarnings({"unchecked", "rawtypes"}) public Optional> getSortedPartitionRanges(CatalogRelation scan) { - return Optional.empty(); + if (!(this instanceof SupportBinarySearchFilteringPartitions)) { + return Optional.empty(); + } + Optional> cached = Env.getCurrentEnv().getSortedPartitionsCacheManager() + .get((SupportBinarySearchFilteringPartitions) this, scan); + return (Optional) cached; } @Override 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 bb3edc19f44fad..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 @@ -27,6 +27,7 @@ import org.apache.doris.catalog.PartitionKey; import org.apache.doris.catalog.PartitionType; import org.apache.doris.catalog.RangePartitionItem; +import org.apache.doris.catalog.SupportBinarySearchFilteringPartitions; import org.apache.doris.catalog.Type; import org.apache.doris.common.AnalysisException; import org.apache.doris.connector.api.Connector; @@ -54,6 +55,8 @@ import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot; import org.apache.doris.mtmv.MTMVSnapshotIf; import org.apache.doris.mtmv.MTMVTimestampSnapshot; +import org.apache.doris.nereids.trees.plans.algebra.CatalogRelation; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; import com.google.common.base.Preconditions; import com.google.common.collect.Lists; @@ -64,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; @@ -87,7 +91,7 @@ * degrades MTMV to conservative over-refresh (the partition never matches its prior snapshot).

*/ public class PluginDrivenMvccExternalTable extends PluginDrivenExternalTable - implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable { + implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable, SupportBinarySearchFilteringPartitions { private static final Logger LOG = LogManager.getLogger(PluginDrivenMvccExternalTable.class); @@ -619,6 +623,58 @@ public Set getPartitionColumnNames(Optional snapshot) { .map(c -> c.getName().toLowerCase()).collect(Collectors.toSet()); } + // ── SupportBinarySearchFilteringPartitions: lets NereidsSortedPartitionsCacheManager cache the + // pre-built SortedPartitionRanges across queries, keyed by the pinned connector snapshot. ── + @Override + public Map getOriginPartitions(CatalogRelation scan) { + // The SAME frozen map the pin exposes — no re-list, so ranges stay consistent with + // nameToPartitionItem (no #65659 TOCTOU). + return getNameToPartitionItems(pinnedSnapshot(scan)); + } + + @Override + public Object getPartitionMetaVersion(CatalogRelation scan) { + // 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()); + } + + @Override + public long getPartitionMetaLoadTimeMillis(CatalogRelation scan) { + // No insert-frequency signal for external tables; 0 = always allow sorting (the manager's + // "skip sort if loaded within cacheSortedPartitionIntervalSecond" heuristic never trips). + return 0L; + } + + // 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 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) { + LogicalFileScan fileScan = (LogicalFileScan) scan; + return MvccUtil.getSnapshotFromContext(this, fileScan.getTableSnapshot(), fileScan.getScanParams()); + } + return MvccUtil.getSnapshotFromContext(this); + } + // ──────────────────── MTMV snapshots ──────────────────── @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java index ad09a5307e03a1..f53530c2837561 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java @@ -99,6 +99,7 @@ private SelectedPartitions pruneExternalPartitions(ExternalTable externalTable, || ctx.getConnectContext().getSessionVariable().enableBinarySearchFilteringPartitions; if (enableBinarySearch && !nameToPartitionItem.isEmpty()) { sortedPartitionRanges = scan.getSelectedPartitions().sortedPartitionRanges + .or(() -> (Optional) externalTable.getSortedPartitionRanges(scan)) .or(() -> Optional.ofNullable(SortedPartitionRanges.build(nameToPartitionItem))); } PartitionPruneResult result = PartitionPruner.pruneWithResult( @@ -108,9 +109,12 @@ private SelectedPartitions pruneExternalPartitions(ExternalTable externalTable, for (String name : prunedPartitions) { PartitionItem item = nameToPartitionItem.get(name); - // Both nameToPartitionItem and sortedPartitionRanges now come from the same frozen - // snapshot, so a missing item is an invariant violation rather than a partition to - // skip. Failing here surfaces the bug instead of silently returning a partial scan. + // Within THIS query, nameToPartitionItem and sortedPartitionRanges are built from the same + // frozen map. On a cross-query cache HIT, sortedPartitionRanges instead reuses ranges built by + // an earlier query keyed by the identical (snapshotId, schemaId) version token -- content is + // identical only via the MVCC determinism premise (same version token => same partition set), + // not because the two maps are literally the same object. A missing item here means that + // premise was violated, so fail loud rather than silently returning a partial scan. Preconditions.checkState(item != null, "pruned partition %s is missing in the selected partitions snapshot", name); selectedPartitionItems.put(name, item); 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 new file mode 100644 index 00000000000000..7e810f54a00c9d --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java @@ -0,0 +1,581 @@ +// 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.common.cache; + +import org.apache.doris.analysis.PartitionValue; +import org.apache.doris.analysis.TableScanParams; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.Env; +import org.apache.doris.catalog.ListPartitionItem; +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.PartitionKey; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.SupportBinarySearchFilteringPartitions; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; +import org.apache.doris.datasource.CatalogIf; +import org.apache.doris.datasource.CatalogMgr; +import org.apache.doris.datasource.ExternalDatabase; +import org.apache.doris.datasource.ExternalMetaCacheMgr; +import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable; +import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.nereids.rules.expression.rules.SortedPartitionRanges; +import org.apache.doris.nereids.trees.plans.RelationId; +import org.apache.doris.nereids.trees.plans.algebra.CatalogRelation; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan; +import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.rpc.RpcException; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +/** + * Unit coverage for wiring external MVCC tables (iceberg/paimon) into + * {@link NereidsSortedPartitionsCacheManager}: cache hit/miss/rebuild-on-version-change, origin-map + * consistency (no #65659 TOCTOU), invalidation, and the {@link ExternalTable#getSortedPartitionRanges} + * delegation contract. + * + *

Drives the manager with a Mockito mock of {@link SupportBinarySearchFilteringPartitions} rather than + * a hand-written fake class: the interface extends {@code TableIf}, whose large method surface makes a + * hand-rolled implementer impractical. Only the methods the manager actually calls are stubbed + * ({@code getOriginPartitions}, {@code getPartitionMetaVersion}, {@code getPartitionMetaLoadTimeMillis}, + * {@code getId}, {@code getName}, {@code getDatabase}).

+ * + *

{@link NereidsSortedPartitionsCacheManager#get} dereferences {@code ConnectContext.get() + * .getSessionVariable()} unconditionally once {@code ConnectContext.get() != null}, so every test needs a + * live {@link ConnectContext} (mirrors the lightweight idiom in {@code LogicalFileScanTest}: a plain + * {@code new ConnectContext()} + {@code setThreadLocalInfo()}, no FE server bootstrap).

+ * + *

The two tests at the bottom of this file additionally drive the REAL production wiring the + * FakeExternalTable-based tests above bypass: {@code PluginDrivenMvccExternalTable#getOriginPartitions} + * / {@code #getPartitionMetaVersion} / {@code #pinnedSnapshot} (via a {@code CALLS_REAL_METHODS} mock, + * the same technique as {@code LogicalFileScanTest}), and {@code ExternalMetaCacheMgr#invalidateTable}'s + * call into this manager (via a real {@link NereidsSortedPartitionsCacheManager} instance reached through + * a mocked {@code Env}).

+ */ +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() { + ConnectContext.remove(); + } + + private static void newLiveConnectContext() { + ConnectContext ctx = new ConnectContext(); + ctx.setThreadLocalInfo(); + } + + private static ListPartitionItem listItem(int value) throws Exception { + Column partitionColumn = new Column("id", PrimitiveType.INT); + PartitionValue partitionValue = new PartitionValue(String.valueOf(value)); + PartitionKey partitionKey = PartitionKey.createPartitionKey( + ImmutableList.of(partitionValue), ImmutableList.of(partitionColumn)); + return new ListPartitionItem(ImmutableList.of(partitionKey)); + } + + /** + * A settable-state mock of {@link SupportBinarySearchFilteringPartitions}: {@link #version} and + * {@link #parts} drive the cache manager's hit/rebuild decision; when {@code withDatabase} is true the + * constructor also stubs a database/catalog pair (names {@link #CTL}/{@link #DB}) so + * {@code TableIdentifier} can build. + */ + private static final class FakeExternalTable { + final SupportBinarySearchFilteringPartitions table = Mockito.mock(SupportBinarySearchFilteringPartitions.class); + Object version = "s1@0"; + Map parts = Maps.newHashMap(); + + @SuppressWarnings({"unchecked", "rawtypes"}) + FakeExternalTable(boolean withDatabase) throws RpcException { + if (withDatabase) { + DatabaseIf db = Mockito.mock(DatabaseIf.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.when(catalog.getName()).thenReturn(CTL); + Mockito.when(db.getFullName()).thenReturn(DB); + Mockito.when(db.getCatalog()).thenReturn(catalog); + Mockito.when(table.getDatabase()).thenReturn(db); + } + Mockito.when(table.getId()).thenReturn(1001L); + Mockito.when(table.getName()).thenReturn(TBL); + Mockito.when(table.getOriginPartitions(Mockito.any())).thenAnswer(inv -> parts); + Mockito.when(table.getPartitionMetaVersion(Mockito.any())).thenAnswer(inv -> version); + Mockito.when(table.getPartitionMetaLoadTimeMillis(Mockito.any())).thenReturn(0L); + } + } + + // ──────────────────── Task 1: getDatabase()==null guards the external wiring contract ──────────────────── + + @Test + public void testManagerReturnsEmptyWhenDatabaseNull() throws Exception { + newLiveConnectContext(); + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + FakeExternalTable t = new FakeExternalTable(false); // getDatabase() -> null (unstubbed mock default) + + Optional> r = mgr.get(t.table, (CatalogRelation) null); + + Assertions.assertFalse(r.isPresent(), + "manager must return empty when getDatabase()==null (guards the external wiring contract)"); + } + + // ──────────────────── Task 2: ExternalTable.getSortedPartitionRanges delegation ──────────────────── + + @Test + public void testGetSortedPartitionRangesEmptyForNonSupportTable() { + // A plain ExternalTable does not implement SupportBinarySearchFilteringPartitions, so the + // delegation must short-circuit to empty WITHOUT touching Env/the cache manager. + ExternalTable table = new ExternalTable(); + Assertions.assertFalse(table.getSortedPartitionRanges(null).isPresent(), + "base ExternalTable (not Support) yields empty"); + } + + // ──────────────────── Task 4: invalidate is safe on an absent key ──────────────────── + + @Test + public void testInvalidateEvictsRanges() { + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + Assertions.assertEquals(0, mgr.getPartitionCaches().estimatedSize(), + "fresh manager is empty; invalidate is a no-op that must not throw"); + Assertions.assertDoesNotThrow(() -> mgr.invalidateTable(CTL, DB, TBL), + "invalidateTable on an absent key must not throw"); + } + + // ──────────────────── Task 5: cache hit / version-rebuild / origin-map consistency ──────────────────── + + @Test + public void testCacheHitThenRebuildOnVersionChange() throws Exception { + newLiveConnectContext(); + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + FakeExternalTable t = new FakeExternalTable(true); + t.parts.put("id=1", listItem(1)); + t.parts.put("id=2", listItem(2)); + + t.version = "s1@0"; + SortedPartitionRanges first = mgr.get(t.table, (CatalogRelation) null).orElse(null); + Assertions.assertNotNull(first, "ranges built and cached at snapshot s1"); + SortedPartitionRanges hit = mgr.get(t.table, (CatalogRelation) null).orElse(null); + Assertions.assertSame(first, hit, "same snapshot => cache hit returns the SAME instance"); + + t.version = "s2@0"; // snapshot advanced (ALTER ADD PARTITION) + t.parts.put("id=3", listItem(3)); + SortedPartitionRanges rebuilt = mgr.get(t.table, (CatalogRelation) null).orElse(null); + Assertions.assertNotSame(first, rebuilt, "version change => rebuild"); + Assertions.assertEquals(3, rebuilt.sortedPartitions.size(), "rebuilt from the new partition set"); + + // Task 4 wiring: dropping the cache by (catalog, db, table) forces the next get() to rebuild too. + mgr.invalidateTable(CTL, DB, TBL); + SortedPartitionRanges afterInvalidate = mgr.get(t.table, (CatalogRelation) null).orElse(null); + Assertions.assertNotSame(rebuilt, afterInvalidate, + "explicit invalidateTable(catalog, db, table) forces a rebuild on the next get()"); + } + + @Test + public void testRangesConsistentWithOriginMap() throws Exception { + // The cached ranges are built from getOriginPartitions(scan); every range id must be a key of + // that same map -- the invariant PruneFileScanPartition's Preconditions relies on (no TOCTOU). + newLiveConnectContext(); + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + FakeExternalTable t = new FakeExternalTable(true); + t.parts.put("id=1", listItem(1)); + t.parts.put("id=2", listItem(2)); + + SortedPartitionRanges r = mgr.get(t.table, (CatalogRelation) null).orElse(null); + Assertions.assertNotNull(r); + r.sortedPartitions.forEach(p -> + Assertions.assertTrue(t.parts.containsKey(p.id), "every range id is a key of the origin map")); + } + + // ──────────────────── Real production wiring: PluginDrivenMvccExternalTable ──────────────────── + // + // The tests above drive the manager with a hand-stubbed SupportBinarySearchFilteringPartitions mock + // and never touch PluginDrivenMvccExternalTable, so they miss the NEW wiring in + // getOriginPartitions/getPartitionMetaVersion/pinnedSnapshot (PluginDrivenMvccExternalTable.java + // around :628-651). This test drives those REAL method bodies: Mockito.CALLS_REAL_METHODS runs every + // unstubbed method for real, so only the connector round-trip (loadSnapshot) and the identity fields + // MvccTableInfo needs (getName/getDatabase) are stubbed -- mirroring the technique in + // LogicalFileScanTest#computeOutputBindsThisReferencesOwnVersionNotLatest. + + @Test + public void testPluginDrivenMvccExternalTableRealOriginPartitionsAndVersion() throws Exception { + Map pinnedPartsT1 = Maps.newHashMap(); + pinnedPartsT1.put("id=1", listItem(1)); + ConnectorMvccSnapshot connectorSnapshotT1 = ConnectorMvccSnapshot.builder() + .snapshotId(42L).schemaId(7L).build(); + PluginDrivenMvccSnapshot pinT1 = new PluginDrivenMvccSnapshot( + connectorSnapshotT1, pinnedPartsT1, Maps.newHashMap()); + + // A DIFFERENT pin for the SAME table at a second @tag reference. With two non-default versions + // pinned and no default ("") entry, the version-BLIND lookup (StatementContext#getSnapshot(TableIf)) + // is ambiguous and gives up (see its javadoc); only the version-AWARE lookup that the + // LogicalFileScan branch of pinnedSnapshot uses resolves the exact t1 reference. MUTATION: + // collapsing pinnedSnapshot to the version-blind fallback makes this test observably diverge + // (an unresolved pin sends getOrMaterialize to materializeLatest() on a field-less mock, or the + // assertions below simply see the wrong values). + ConnectorMvccSnapshot connectorSnapshotT2 = ConnectorMvccSnapshot.builder() + .snapshotId(99L).schemaId(3L).build(); + PluginDrivenMvccSnapshot pinT2 = new PluginDrivenMvccSnapshot( + connectorSnapshotT2, Maps.newHashMap(), Maps.newHashMap()); + + // NOTE: table's default answer is CALLS_REAL_METHODS, so every stub below MUST use the + // doReturn(...).when(table)... form (never when(table.foo()).thenReturn(...)) -- the latter would + // evaluate table.foo() for REAL as part of recording the stub, exactly the pitfall Mockito spies + // have, and several of these real bodies dereference fields this field-less mock never set. + PluginDrivenMvccExternalTable table = + Mockito.mock(PluginDrivenMvccExternalTable.class, Mockito.CALLS_REAL_METHODS); + ExternalDatabase database = Mockito.mock(ExternalDatabase.class); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.doReturn(TBL).when(table).getName(); + Mockito.doReturn(database).when(table).getDatabase(); + Mockito.when(database.getFullName()).thenReturn(DB); + Mockito.when(database.getCatalog()).thenReturn((CatalogIf) catalog); + Mockito.when(catalog.getName()).thenReturn(CTL); + // Not under test here (see LogicalFileScanTest precedent): bypass its real body, which needs + // schema/catalog wiring this bare mock doesn't carry. + Mockito.doReturn(SelectedPartitions.NOT_PRUNED).when(table).initSelectedPartitions(Mockito.any()); + + TableScanParams tagT1 = new TableScanParams("tag", ImmutableMap.of(), ImmutableList.of("t1")); + TableScanParams tagT2 = new TableScanParams("tag", ImmutableMap.of(), ImmutableList.of("t2")); + Mockito.doReturn(pinT1).when(table).loadSnapshot(Optional.empty(), Optional.of(tagT1)); + Mockito.doReturn(pinT2).when(table).loadSnapshot(Optional.empty(), Optional.of(tagT2)); + + ConnectContext ctx = new ConnectContext(); + StatementContext stmtCtx = new StatementContext(ctx, null); + ctx.setStatementContext(stmtCtx); + ctx.setThreadLocalInfo(); + try { + // Pin via loadSnapshots (not a hand-rolled key) so the version key is computed by the SAME + // function the lookup uses -- the test must not hand-roll a key and accidentally agree with + // itself. + stmtCtx.loadSnapshots(table, Optional.empty(), Optional.of(tagT1)); + stmtCtx.loadSnapshots(table, Optional.empty(), Optional.of(tagT2)); + + LogicalFileScan scan = new LogicalFileScan(new RelationId(1), table, + Collections.singletonList(DB), Collections.emptyList(), + Optional.empty(), Optional.empty(), Optional.of(tagT1), Optional.empty()); + + Map origin = table.getOriginPartitions(scan); + Object version = table.getPartitionMetaVersion(scan); + + 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(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(); + } + } + + // ──────────────────── Cache B version is the frozen partition NAME-SET for ALL engines ───── + // + // 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}, + * 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); + CatalogIf catalog = Mockito.mock(CatalogIf.class); + Mockito.doReturn(TBL).when(table).getName(); + Mockito.doReturn(database).when(table).getDatabase(); + Mockito.when(database.getFullName()).thenReturn(DB); + 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(); + // 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()); + } + + private static PluginDrivenMvccSnapshot sentinelPin(Map parts) { + ConnectorMvccSnapshot sentinelSnapshot = ConnectorMvccSnapshot.builder() + .snapshotId(-1L).schemaId(0L).build(); + 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(); + 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 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(); + 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(), + "an empty partition set (snapshot-less or not) yields no ranges to cache " + + "(SortedPartitionRanges.build(emptyMap) == null)"); + } finally { + ConnectContext.remove(); + } + } + + // ──────────────────── Real production wiring: ExternalMetaCacheMgr.invalidateTable ──────────────────── + + @Test + public void testExternalMetaCacheMgrInvalidateTableDropsRangesCacheEntry() throws Exception { + // Drives the REAL ExternalMetaCacheMgr.invalidateTable(...) (the new call at + // ExternalMetaCacheMgr.java:217-220), not NereidsSortedPartitionsCacheManager directly, so the + // production wiring between the two managers is exercised end-to-end. + 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"); + + long catalogId = 7L; + 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(catalogId)).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); + metaCacheMgr.invalidateTable(catalogId, DB, TBL); + } + + Assertions.assertEquals(0, rangesCacheMgr.getPartitionCaches().estimatedSize(), + "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)); + } +} 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. diff --git a/plan-doc/designs/2026-07-20-external-partition-derived-cache-design.md b/plan-doc/designs/2026-07-20-external-partition-derived-cache-design.md new file mode 100644 index 00000000000000..274e5d51785257 --- /dev/null +++ b/plan-doc/designs/2026-07-20-external-partition-derived-cache-design.md @@ -0,0 +1,179 @@ +# 外表分区"派生视图"二级缓存设计(连接器视图缓存 + 接入 NereidsSortedPartitionsCacheManager) + +- 日期:2026-07-20 +- 分支:`branch-catalog-spi` +- 状态:设计已评审通过,待实现计划 +- 关联:CACHE-P1(翻闸弃二级缓存决策)、#65659(分区裁剪 TOCTOU freeze) + +## 1. 问题背景 + +catalog-SPI 翻闸(CACHE-P1 决策)把 legacy fe-core 里引擎特定的"分区派生视图"二级缓存整层删除,改为**每查询直连列举分区**。代价(已登记为 CACHE-P1 偏差、known-degradation)分两块,按引擎轻重不同: + +1. **远程枚举成本**:iceberg 分区表每查询一次 `PARTITIONS` 元数据表扫描(loadTable + 遍历 manifest-list + 全 manifest + avro 解码);maxcompute 每查询一次 ODPS `getPartitions()`;hive 每查询把 HMS 名单解析成 `ConnectorPartitionInfo`(原始名单已被 `CachingHmsClient` 缓存,此处是解析 CPU)。 +2. **派生视图重建成本**:fe-core 每查询把分区名单构建成 `nameToPartitionItem`(`PartitionItem`),并在 `PruneFileScanPartition` 里用 `SortedPartitionRanges.build()` 重建二分查找结构(O(n) 建 range + O(n log n) 排序)。legacy 里这份 `SortedPartitionRanges` 是缓存复用的(`HiveExternalMetaCache.HivePartitionValues` 构造函数里建一次、跨查询复用),翻闸后变成每查询重建。 + +对**大量分区**表,这两块都显著。本设计恢复这两层缓存,同时满足 SPI 隔离、且不复发 #65659 的 TOCTOU。 + +## 2. 目标 / 非目标 + +**目标** +- 恢复跨查询的分区派生视图缓存,消除上述两块重复成本。 +- 严格满足 SPI 隔离:fe-core 保持连接器无关;连接器侧缓存建在 SPI 线以下。 +- 不复发 #65659 的分区视图查询内分叉(TOCTOU)。 +- 通用框架,全 SPI 引擎(`SPI_READY_TYPES = jdbc, es, trino-connector, max_compute, paimon, iceberg, hms`)可受益;按收益接线(iceberg/paimon/hive/maxcompute 先接,jdbc/es 分区退化可暂 no-op)。 + +**非目标** +- 不引入全局 as-of 快照语义;跨查询采用 bounded-staleness 契约(TTL + 失效钩子)。 +- 不改变 `PruneFileScanPartition` 的二分裁剪算法(`PartitionPruner.binarySearchFiltering` 保持原样)。 +- 不为带谓词的 `listPartitions`(当前不存在此调用)设计缓存分桶。 +- **不考虑 `use_meta_cache=false`**:新 SPI 框架下 `use_meta_cache` **恒为 true**(连接器始终经元数据缓存),无直连绕过路径需设计。SHOW PARTITIONS / partitions-TVF 的"取新"仍由既有 per-call fresh 路径(`listPartitionNamesFresh` 等)承担,与本缓存正交、绕过缓存 A。 + +## 3. 现状梳理(设计所依赖的事实) + +- **每语句冻结层已存在(相当于 Trino 的 per-transaction memoizer)**:hive/iceberg/paimon 都是 `PluginDrivenMvccExternalTable`;分区视图在 `materializeLatest()`(`PluginDrivenMvccExternalTable.java:127`)**每语句只物化一次**,冻结进 `PluginDrivenMvccSnapshot`(存于 `StatementContext.snapshots`,per-statement 实例字段)。`PruneFileScanPartition` 读的是冻结的 `SelectedPartitions`,绝不回连缓存——这是 #65659 TOCTOU 不复发的结构性原因。 +- **裁剪路径 `listPartitions` 永远不带 filter**:全部 4 个调用点(`PluginDrivenExternalTable.java:831/:887`、`PluginDrivenMvccExternalTable.java:271`、`ShowPartitionsCommand.java:300`)传 `Optional.empty()`。 +- **二分裁剪机制在分支中完好且已在用**:`PartitionPruner.pruneWithResult`(`PartitionPruner.java:163`)在 `sortedPartitionRanges.isPresent()` 时走 `binarySearchFiltering`(`:204-209, :255`),由 session 变量 `enableBinarySearchFilteringPartitions` 控制;#65659 合并后 `PruneFileScanPartition` 每查询用 `.or(() -> SortedPartitionRanges.build(nameToPartitionItem))`(`PruneFileScanPartition.java:101-102`)现建 ranges 后照常二分——功能与 legacy 一致,唯缺缓存复用。 +- **fe-core 已有引擎无关、按版本 keyed 的 ranges 缓存**:`NereidsSortedPartitionsCacheManager`(`common/cache/NereidsSortedPartitionsCacheManager.java`),key=`TableIdentifier(catalog,db,table)`,`PartitionCacheContext(tableId, partitionMetaVersion, sortedPartitionRanges)`,`get()`(`:74`)用 `getPartitionMetaVersion(scan)` 校验版本、变更即重建(`:98-102`),`loadCache()`(`:117`)带"插入过频跳过排序"启发式(`:127-131`),软值 + FE 配置 TTL。**OLAP 表已通过 `PruneOlapScanPartition.java:161-168` 在用**。其接口 `SupportBinarySearchFilteringPartitions`(`catalog/SupportBinarySearchFilteringPartitions.java`)javadoc 明言"you can save the partition's meta snapshot id in the CatalogRelation and get the partitions by the snapshot id"——为外表 MVCC 快照场景预留。 +- **外表当前完全绕过它**:`ExternalTable.getSortedPartitionRanges(scan)`(`ExternalTable.java:527`)返回 `Optional.empty()` 空桩,且 #65659 合并后**已无任何调用者**(死代码)。 +- **已有连接器侧缓存基建**:`fe-connector-cache` 的 `CacheSpec`(属性模型 `meta.cache...(enable|ttl-second|capacity)`)+ `MetaCacheEntry`(Caffeine,contextual-only + manual-miss,generation 计数失效)。iceberg 已有 `IcebergPartitionCache`(key=`(TableIdentifier, snapshotId)` → `List` **原始行**)、`IcebergLatestSnapshotCache`(`(snapshotId, schemaId)` 快照钉子);hive 有 `CachingHmsClient`(缓存 `listPartitionNames`/`getPartitions` 原始 HMS 元数据)。**但派生视图(`ConnectorMvccPartitionView` / `List`)每次重算**。 +- **连接器失效缝**:`Connector.invalidateTable/invalidateDb/invalidateAll/invalidatePartition`(`fe-connector-api/.../Connector.java:312/316/324/336`,默认 no-op,引擎 override)。REFRESH TABLE(`RefreshManager.refreshTableInternal` → `connector.invalidateTable`)/CATALOG(`PluginDrivenExternalCatalog.onRefreshCache` → `connector.invalidateAll`)/DB + HMS 事件(ADD/DROP/REFRESH_PARTITIONS → `connector.invalidatePartition`)已全部 fan-out 到这 4 个钩子。 +- **session=user 凭证隔离**:iceberg 连接器缓存在 `iceberg.rest.session=user` 下被置 `null` 禁用(`check-authz-cache-sharding.sh` 标记守护)。 + +## 4. 方案总览:两层互补缓存 + +``` +Nereids 裁剪 (PruneFileScanPartition.pruneExternalPartitions) + │ nameToPartitionItem = scan.getSelectedPartitions().selectedPartitions // 冻结 (T1) + │ ranges = scan.getSelectedPartitions().sortedPartitionRanges // 冻结 (#65659), 分支恒空 + │ .or(() -> externalTable.getSortedPartitionRanges(scan)) // ← 复活死桩 → 缓存 B + │ .or(() -> Optional.ofNullable(SortedPartitionRanges.build(nameToPartitionItem))) // 最后兜底 + │ → PartitionPruner.pruneWithResult(..., ranges) → binarySearchFiltering // 机制不变 + ▼ +[缓存 B · fe-core 已存在] NereidsSortedPartitionsCacheManager + key = (catalog, db, table) + validity = getPartitionMetaVersion(scan) // = pinned metaVersion, 见 §6 + value = 预建 SortedPartitionRanges + origin = getOriginPartitions(scan) → pinned 快照的冻结 nameToPartitionItem + ── 消除: 每查询重建 SortedPartitionRanges (大量分区 CPU) + ▲ +[Tier-B 冻结 · 已存在] PluginDrivenMvccSnapshot (每语句物化一次, materializeLatest) + ▲ ── 唯一读下层缓存处 + │ +[缓存 A · 新增, SPI 线以下] ConnectorPartitionViewCache (各连接器 final 字段) + key = (db, table, snapshotId, schemaId) + value = List / ConnectorMvccPartitionView + ── 消除: 远程枚举 (iceberg PARTITIONS 扫描 / ODPS getPartitions / hive 名单解析) + ▲ +[remote] iceberg catalog / hive HMS / odps +``` + +对照 Trino:缓存 A = Trino 的"共享 TTL 层"(`SharedHiveMetastoreCache`,跨查询、可陈旧、SPI 线以下);Tier-B 冻结层 = Trino 的"per-transaction memoizer"(首次触碰即冻结、查询内一致)。区别是 Doris 的 per-statement 冻结层已存在,故只需补共享层 A + 复用已有 ranges 缓存 B。 + +## 5. 缓存 A:`ConnectorPartitionViewCache`(SPI 线以下) + +**放置**:`fe/fe-connector/fe-connector-cache/.../cache/ConnectorPartitionViewCache.java`(共享工具,非 SPI 接口;与 `CachingHmsClient`/`IcebergPartitionCache` 同一构建方式)。各连接器持有一个 final 字段实例。 + +**形态**:内部一个 `MetaCacheEntry`,沿用 contextual-only + manual-miss 并发模型。 +- key `PartitionViewCacheKey`:`(db, table, snapshotId, schemaId)`。**不含 filter**(裁剪路径恒空;带 filter 的 `listPartitions` 由 loader 一行 `if (filter.isPresent()) return uncachedLoad();` 绕过)。 +- value:`List`(iceberg 另可缓存派生的 `ConnectorMvccPartitionView`)。 +- 配置:`CacheSpec` 键 `meta.cache..partition_view.(enable|ttl-second|capacity)`,默认 TTL 86400s、ON、capacity 与同引擎其它 entry 一致。 + +**接入点**(把原本昂贵的枚举+派生 body 包进 `cache.get(key, () -> body)`): +- iceberg:`IcebergConnectorMetadata.getMvccPartitionView` / `listPartitions`,叠在现有 `IcebergPartitionCache`(raw 行)之上,缓存派生视图。 +- paimon:`getMvccPartitionView` / `listPartitions`,`(snapshotId, schemaId)` keyed。 +- hive:`HiveConnectorMetadata.listPartitions`,`(db, table)` keyed(snapshotId=-1)+ TTL/失效。 +- maxcompute:`listPartitions`,`(db, table)` keyed + TTL/失效。 +- jdbc/es/trino:分区退化/罕见,框架可接,本期 no-op。 + +**失效**:在各连接器已 override 的 `invalidateTable/invalidateDb/invalidateAll/invalidatePartition` 里加 `partitionViewCache.invalidate*`。`invalidatePartition`(SPI 只带 values 不带 name)对本缓存按 `(db,table)` 整表失效(本就是整表视图,粒度天然对齐、偏保守=安全)。iceberg 因 key 带 snapshotId,REFRESH 后新 snapshot 自然 miss,失效为冗余保险。 + +**session=user**:沿用现有规则——`iceberg.rest.session=user` 下该连接器缓存置 `null` 禁用,挂 `check-authz-cache-sharding.sh` 标记。 + +## 6. 缓存 B:接入 `NereidsSortedPartitionsCacheManager` + +**让外表实现 `SupportBinarySearchFilteringPartitions`**(`PluginDrivenMvccExternalTable` 覆盖 hive/iceberg/paimon;`PluginDrivenExternalTable` 覆盖 maxcompute 等非 MVCC): +- `getOriginPartitions(scan)`:返回该 scan pinned 快照的**冻结 `nameToPartitionItem`**(`PluginDrivenMvccSnapshot.getNameToPartitionItem()`,数据来自缓存 A / 冻结层,非重新远程列举)。 +- `getPartitionMetaVersion(scan)`:版本令牌(见下)。 +- `getPartitionMetaLoadTimeMillis(scan)`:该分区元数据物化时刻(喂"插入过频跳过排序"启发式;取快照 pin / 缓存 A 条目载入时刻)。 + +**复活死桩**:`ExternalTable.getSortedPartitionRanges(scan)` 改为委托 `Env.getCurrentEnv().getSortedPartitionsCacheManager().get(this, scan)`——与 OLAP 的 `PruneOlapScanPartition` 同一套。 + +**裁剪路径接线**:`PruneFileScanPartition.pruneExternalPartitions` 的 ranges 获取改为: +```java +sortedPartitionRanges = scan.getSelectedPartitions().sortedPartitionRanges // 冻结优先 (#65659) + .or(() -> (Optional) externalTable.getSortedPartitionRanges(scan)) // 缓存 B + .or(() -> Optional.ofNullable(SortedPartitionRanges.build(nameToPartitionItem))); // 兜底 +``` +保留 #65659"冻结字段优先"的语义;缓存 B 命中即复用预建 ranges;缓存 B 返回空(跳过排序启发式/禁用)时退回现建;二者皆从 pinned 快照的同一 map 派生。 + +## 7. 版本令牌(缓存 B 正确性与两层联动) + +统一走**已每语句穿到底的 SPI 类型 `ConnectorMvccSnapshot` 上新增的一个 `metaVersion` 字段**(不新增 SPI 方法,只在既有类型加字段): +- **iceberg / paimon**:`metaVersion = (snapshotId, schemaId)`——不可变、精确(`ConnectorMvccSnapshot.getSnapshotId():55 / getSchemaId():73` 已有;iceberg 由 `loadLatestSnapshotPin`(`IcebergConnectorMetadata.java:1630`)原子捕获)。新快照自然换 key。 +- **hive / 非 MVCC**:`metaVersion = 连接器维护的单调 generation`(连接器内 `ConcurrentHashMap`,`invalidateTable/invalidatePartition` 时 bump、缓存 A reload 时 bump)。廉价 in-memory、精确,且**让缓存 A 与缓存 B 版本联动**(B 的 ranges 绝不比 A 的视图更旧)。 + +`getPartitionMetaVersion(scan)` 从 scan 的 pinned 快照读出该 `metaVersion`(scan 携带 `tableSnapshot`/`scanParams` → `StatementContext.getSnapshot` → pinned `PluginDrivenMvccSnapshot` → `ConnectorMvccSnapshot.metaVersion`)。 + +## 8. 正确性论证(两层皆不复发 #65659 TOCTOU) + +- **缓存 A**:只在 `materializeLatest()` 那一次被读,读出即冻结进 `PluginDrivenMvccSnapshot`;`PruneFileScanPartition` 只读冻结副本,绝不回连缓存 A。同一 key 并发命中返回同一不可变 value。跨查询:MVCC 令牌保证永不把 S+1 当 S;hive 为 bounded-staleness(TTL + 失效钩子,= `use_meta_cache` 契约)。 +- **缓存 B**:`getPartitionMetaVersion(scan)` = pinned 快照/generation,`getOriginPartitions(scan)` 返回同一 pinned 快照的冻结 `nameToPartitionItem`——ranges 与 `nameToPartitionItem` **同源**;版本不符即重建。故 `prunedPartitions ⊆ nameToPartitionItem.keySet()` 恒成立,`PruneFileScanPartition` 的 `Preconditions.checkState(item != null, ...)`(#65659)永不触发。 +- **查询内一致**由既有冻结层保证(物化一次);**跨查询**由版本令牌(MVCC)/ TTL+失效(hive)保证 bounded staleness。二者组合等价于 Trino 的"共享 TTL 层 + per-transaction memoizer"。 + +## 9. 隔离与配置 + +- **SPI 隔离**:缓存 A 完全在 SPI 线以下(连接器 final 字段,缓存 SPI 类型),fe-core 一行不改。缓存 B 在 fe-core,但 key=`(表)+不透明 SPI 令牌`、`getOriginPartitions` 只消费 SPI 输出、失效走连接器钩子——不碰引擎内部,与 OLAP 同构。 +- **session=user**:缓存 A 沿用"置 null 禁用"。缓存 B 只存 `(表,快照)` 维度 ranges、**不含任何凭证**,与 OLAP 同样跨用户共享安全(同 snapshotId ⇒ 同分区集),不禁用。 +- **配置开关**:缓存 A `meta.cache..partition_view.(enable|ttl-second|capacity)`(默认 ON/86400s),`enable=false`/`ttl=0` 一键关(对应 Trino `cache-partitions=false` 逃生阀);缓存 B 复用现有 session 变量 `enableBinarySearchFilteringPartitions` + `cacheSortedPartitionIntervalSecond` + FE 配置(`NereidsSortedPartitionsCacheManager` 现有的 `sortedPartitionTableManageNum`/`expireSortedPartitionTableInFeSecond`)。 + +## 10. 失效汇总(复用现成管线,零新增管线) + +| 触发 | 现有 fan-out | 本设计追加 | +|---|---|---| +| REFRESH TABLE / HMS REFRESH_TABLE | `RefreshManager.refreshTableInternal` → `connector.invalidateTable` | 钩子内 `partitionViewCache.invalidateTable` + bump generation;`NereidsSortedPartitionsCacheManager.invalidate(catalog,db,table)` | +| REFRESH CATALOG | `PluginDrivenExternalCatalog.onRefreshCache` → `connector.invalidateAll` | `partitionViewCache.invalidateAll`;ranges 缓存对应库表失效 | +| REFRESH DB | `connector.invalidateDb` | `partitionViewCache.invalidateDb` | +| HMS ADD/DROP/REFRESH_PARTITIONS | `connector.invalidatePartition` | `partitionViewCache.invalidateTable`(整表) + bump generation → 缓存 B 版本自然失配重建 | +| DROP/RENAME TABLE, DROP DB | `connector.invalidateTable/invalidateDb` | 同上 | + +## 11. 测试 + +**单元测试(我方跑)** +- 缓存 A:per-engine miss→hit;按 `(snapshotId,schemaId)` 分桶(不同快照互不串味);4 钩子失效各自生效;`session=user` 下禁用(返回 null 走直连);带 filter 绕过。仿 `IcebergPartitionCacheTest`/`CachingHmsClientTest`。 +- 缓存 B:外表实现 `SupportBinarySearchFilteringPartitions` 三方法;版本变更(snapshotId 变 / generation bump)触发重建;死桩 `getSortedPartitionRanges` 委托生效;`getOriginPartitions` 与冻结 map 同源 → `prunedPartitions ⊆ keySet`,`Preconditions` 不触发;"插入过频跳过排序"启发式。 +- `PruneFileScanPartition`:二分裁剪结果与 legacy/兜底 `build()` 一致的回归(同一谓词、同一分区集 → 同一 pruned 集)。 +- 复用/借鉴 `BinarySearchPartitionInconsistencyTest`(#65659)验证冻结一致性不被破坏。 + +**e2e(用户跑)** +- 大量分区外表重复查询:第二次起命中缓存 A(无远程枚举)、命中缓存 B(无 ranges 重建)。 +- `ALTER TABLE ADD/DROP PARTITION` 后失效可见(下次查询反映新分区集)。 +- iceberg 时间旅行 `FOR VERSION/TIME AS OF` 不串味(不同 snapshotId 独立缓存)。 +- `set enableBinarySearchFilteringPartitions=false` / `meta.cache..partition_view.enable=false` 回退行为。 +- `REFRESH TABLE`/`REFRESH CATALOG` 立即生效。 + +## 12. 风险与回滚 + +- 缓存 A 纯旁路:`enable=false` 即回到当前每查询枚举行为。 +- 缓存 B 是复用既有 OLAP 机制 + 复活死桩:`enableBinarySearchFilteringPartitions=false` 即回到 #65659 的每查询 `build()` 行为。 +- 均不改冻结层、不动 `PartitionPruner`/`PruneFileScanPartition` 的裁剪算法,故不影响 #65659 的正确性结论。 +- 主要风险面 = 失效遗漏导致 bounded staleness 超预期;缓解 = 两层失效均挂同一批已验证的连接器钩子 + TTL 上限。 + +## 13. 影响文件清单(预估) + +**新增** +- `fe/fe-connector/fe-connector-cache/.../cache/ConnectorPartitionViewCache.java`(+ 测试) +- `fe/fe-connector/fe-connector-cache/.../cache/PartitionViewCacheKey.java` + +**修改** +- SPI:`fe-connector-*/.../ConnectorMvccSnapshot`(加 `metaVersion` 字段 + builder)。 +- iceberg:`IcebergConnector`(持有缓存 A + session=user 置 null + 4 钩子)、`IcebergConnectorMetadata`(`getMvccPartitionView`/`listPartitions` 包缓存、`beginQuerySnapshot` 填 `metaVersion`)。 +- paimon:`PaimonConnector`/`PaimonConnectorMetadata` 同构。 +- hive:`HiveConnector`(缓存 A + generation map + 钩子)、`HiveConnectorMetadata`(`listPartitions` 包缓存、`beginQuerySnapshot` 填 generation)。 +- maxcompute:`MaxComputeConnector`/`MaxComputeConnectorMetadata` 同 hive 模式。 +- fe-core:`ExternalTable`(`getSortedPartitionRanges` 委托 `NereidsSortedPartitionsCacheManager`)、`PluginDrivenMvccExternalTable`/`PluginDrivenExternalTable`(实现 `SupportBinarySearchFilteringPartitions` 三方法)、`PruneFileScanPartition`(ranges 获取链加缓存 B)。 +- 失效:各连接器 `invalidate*` 钩子内追加两层失效调用。 + +## 14. 遗留 / follow-up(本设计不做) + +- jdbc/es/trino 连接器接入缓存 A(分区退化,收益低,待需求驱动)。 +- 若 profiling 显示某引擎 `getMvccPartitionView` 派生(dedup+buildRange+merge)仍是瓶颈,可在连接器内进一步拆分缓存粒度。 diff --git a/plan-doc/plans/2026-07-21-partition-ranges-cache-B-iceberg-paimon.md b/plan-doc/plans/2026-07-21-partition-ranges-cache-B-iceberg-paimon.md new file mode 100644 index 00000000000000..43122849289dd6 --- /dev/null +++ b/plan-doc/plans/2026-07-21-partition-ranges-cache-B-iceberg-paimon.md @@ -0,0 +1,419 @@ +# Cache B — External SortedPartitionRanges Reuse (iceberg + paimon) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore cross-query caching of the pre-built `SortedPartitionRanges` for MVCC external tables (iceberg, paimon) by wiring them into the existing fe-core `NereidsSortedPartitionsCacheManager`, eliminating the per-query rebuild for many-partition pruning — without SPI changes or re-introducing the #65659 TOCTOU. + +**Architecture:** External MVCC tables implement the existing `SupportBinarySearchFilteringPartitions` interface (the same one OLAP tables use), sourcing the version token from the already-pinned `ConnectorMvccSnapshot(snapshotId, schemaId)` and the origin partition map from the frozen per-statement `PluginDrivenMvccSnapshot`. The dead `ExternalTable.getSortedPartitionRanges` stub is revived to delegate to `NereidsSortedPartitionsCacheManager`, and `PruneFileScanPartition` consults it before its build-fresh fallback. Because the cache is keyed by the pinned snapshot and reads the frozen map, ranges stay consistent with `nameToPartitionItem` (no TOCTOU). + +**Tech Stack:** Java 8, Maven (Doris fe reactor), JUnit 5, Caffeine (via existing `NereidsSortedPartitionsCacheManager`). + +## Global Constraints + +- fe-core stays connector-agnostic: no engine-specific (iceberg/paimon) types leak into `ExternalTable`/`PluginDrivenMvccExternalTable` generic methods; the version token is the opaque `(snapshotId, schemaId)` pair from the SPI type `ConnectorMvccSnapshot`. +- Do NOT change the pruning algorithm in `PartitionPruner.binarySearchFiltering`. +- Preserve #65659 semantics: `PruneFileScanPartition` reads the frozen `scan.getSelectedPartitions().sortedPartitionRanges` first; the cache is only a fallback source before `SortedPartitionRanges.build(...)`. +- Gated by the existing session variable `enableBinarySearchFilteringPartitions` (already checked inside `NereidsSortedPartitionsCacheManager.get`). +- `use_meta_cache` is invariantly `true`; no bypass path. +- Scope of THIS plan: iceberg + paimon only (true MVCC with a real `snapshotId`). hive/maxcompute (which need a connector generation token) and Cache A (connector-view cache) are separate plans. +- Build/test invocation (per repo gotchas): run fe-core unit tests with + `mvn -f /mnt/disk1/yy/git/doris/fe/pom.xml -pl fe-core -am surefire:test -Dtest= -DfailIfNoTests=false -Dmaven.build.cache.enabled=false` + (a full `mvn -f fe/pom.xml install -DskipTests -Dmaven.build.cache.enabled=false` once first, so deps + `${revision}` resolve). + +--- + +## File Structure + +- `fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java` — implement `SupportBinarySearchFilteringPartitions` (3 methods) [Task 1]. +- `fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java` — revive `getSortedPartitionRanges` to delegate to the cache manager [Task 2]. +- `fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java` — insert the cache lookup into the ranges `.or(...)` chain [Task 3]. +- `fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java` — invalidate the ranges cache alongside the schema cache on REFRESH/events [Task 4]. +- Tests: `fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java` [Task 1 & 5]. + +--- + +### Task 1: External MVCC tables implement `SupportBinarySearchFilteringPartitions` + +**Files:** +- Modify: `fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java` (class decl `:89`; add 3 methods near the partition-view section around `:581`) +- Test: `fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java` (create) + +**Interfaces:** +- Consumes: `SupportBinarySearchFilteringPartitions` (`org.apache.doris.catalog`, methods `getOriginPartitions(CatalogRelation)`, `getPartitionMetaVersion(CatalogRelation)`, `getPartitionMetaLoadTimeMillis(CatalogRelation)`); `PluginDrivenMvccSnapshot.getNameToPartitionItem()`, `.getConnectorSnapshot()`; `ConnectorMvccSnapshot.getSnapshotId()/getSchemaId()`; `MvccUtil.getSnapshotFromContext(TableIf, Optional, Optional)`. +- Produces: `PluginDrivenMvccExternalTable implements SupportBinarySearchFilteringPartitions`; `getPartitionMetaVersion` returns a `String` token `"@"`. + +- [ ] **Step 1: Write the failing test** + +Create `fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java`. It drives `NereidsSortedPartitionsCacheManager` with a hand-rolled fake table that mimics the external contract (version token switches to force a rebuild), so it needs no connector or FE server. + +```java +// Licensed to the Apache Software Foundation (ASF) under one ... (standard ASF header) +package org.apache.doris.common.cache; + +import org.apache.doris.analysis.PartitionValue; +import org.apache.doris.catalog.Column; +import org.apache.doris.catalog.DatabaseIf; +import org.apache.doris.catalog.ListPartitionItem; +import org.apache.doris.catalog.PartitionItem; +import org.apache.doris.catalog.PartitionKey; +import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.SupportBinarySearchFilteringPartitions; +import org.apache.doris.nereids.rules.expression.rules.SortedPartitionRanges; +import org.apache.doris.nereids.trees.plans.algebra.CatalogRelation; +import org.apache.doris.utframe.TestWithFeService; + +import com.google.common.collect.Maps; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Optional; + +public class NereidsSortedPartitionsCacheManagerExternalTest extends TestWithFeService { + + /** Minimal external-style table: version token and origin map are settable to drive cache behavior. */ + private static class FakeExternalTable implements SupportBinarySearchFilteringPartitions { + Object version = "s1@0"; + Map parts = Maps.newHashMap(); + + @Override public Map getOriginPartitions(CatalogRelation scan) { return parts; } + @Override public Object getPartitionMetaVersion(CatalogRelation scan) { return version; } + @Override public long getPartitionMetaLoadTimeMillis(CatalogRelation scan) { return 0L; } + @Override public long getId() { return 1001L; } + @Override public String getName() { return "t"; } + @Override public DatabaseIf getDatabase() { return null; } // manager returns empty when db==null; overridden below + } + + private static PartitionItem listItem(int v) throws Exception { + PartitionKey k = PartitionKey.createListPartitionKeyWithTypes( + java.util.Collections.singletonList(new PartitionValue(String.valueOf(v))), + java.util.Collections.singletonList(new Column("id", PrimitiveType.INT).getType()), false); + return new ListPartitionItem(java.util.Collections.singletonList(k)); + } + + @Test + public void testExternalRangesRebuildOnVersionChange() throws Exception { + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + // getDatabase()==null short-circuits get() to empty; assert that contract holds so we know the + // manager consults getDatabase()/getPartitionMetaVersion (the wiring points Task 2/3 depend on). + FakeExternalTable t = new FakeExternalTable(); + Optional> r = mgr.get(t, (CatalogRelation) null); + Assertions.assertFalse(r.isPresent(), + "manager must return empty when getDatabase()==null (guards the external wiring contract)"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -f /mnt/disk1/yy/git/doris/fe/pom.xml -pl fe-core -am surefire:test -Dtest=NereidsSortedPartitionsCacheManagerExternalTest -DfailIfNoTests=false -Dmaven.build.cache.enabled=false` +Expected: COMPILE FAIL — `PluginDrivenMvccExternalTable` does not yet implement the interface referenced conceptually; and the test compiles only once `SupportBinarySearchFilteringPartitions` is importable (it is). Actually this test compiles today; it will PASS trivially. Its purpose is to lock the `getDatabase()==null ⇒ empty` contract. Proceed to implement the real methods on the table and re-point coverage in Task 5. Mark this step done once the test runs GREEN as a contract guard. + +- [ ] **Step 3: Implement the interface on `PluginDrivenMvccExternalTable`** + +Change the class declaration at `:89` to add the interface: +```java +public class PluginDrivenMvccExternalTable extends PluginDrivenExternalTable + implements org.apache.doris.catalog.SupportBinarySearchFilteringPartitions { +``` +Add these three methods next to `getNameToPartitionItems` (around `:581`). Cast the generic `CatalogRelation` to `LogicalFileScan` for the per-reference snapshot selectors (external partition pruning fires only on `LogicalFileScan`): +```java + // ── SupportBinarySearchFilteringPartitions: lets NereidsSortedPartitionsCacheManager cache the + // pre-built SortedPartitionRanges across queries, keyed by the pinned connector snapshot. ── + @Override + public Map getOriginPartitions(CatalogRelation scan) { + // The SAME frozen map the pin exposes — no re-list, so ranges stay consistent with + // nameToPartitionItem (no #65659 TOCTOU). + return getNameToPartitionItems(pinnedSnapshot(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(); + } + + @Override + public long getPartitionMetaLoadTimeMillis(CatalogRelation scan) { + // No insert-frequency signal for external tables; 0 = always allow sorting (the manager's + // "skip sort if loaded within cacheSortedPartitionIntervalSecond" heuristic never trips). + return 0L; + } + + private Optional pinnedSnapshot(CatalogRelation scan) { + if (scan instanceof LogicalFileScan) { + LogicalFileScan fileScan = (LogicalFileScan) scan; + return MvccUtil.getSnapshotFromContext(this, fileScan.getTableSnapshot(), fileScan.getScanParams()); + } + return MvccUtil.getSnapshotFromContext(this); + } +``` +Add imports if missing: `org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot`, `org.apache.doris.datasource.mvcc.MvccUtil`, `org.apache.doris.nereids.trees.plans.algebra.CatalogRelation`, `org.apache.doris.nereids.trees.plans.logical.LogicalFileScan`, `java.util.Optional`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -f /mnt/disk1/yy/git/doris/fe/pom.xml -pl fe-core -am surefire:test -Dtest=NereidsSortedPartitionsCacheManagerExternalTest -DfailIfNoTests=false -Dmaven.build.cache.enabled=false` +Expected: PASS (compiles with the new interface; contract guard green). + +- [ ] **Step 5: Commit** + +```bash +git add fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java \ + fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java +git commit -m "[feat](catalog) external MVCC tables implement SupportBinarySearchFilteringPartitions (snapshot-keyed ranges)" +``` + +--- + +### Task 2: Revive `ExternalTable.getSortedPartitionRanges` to delegate to the cache manager + +**Files:** +- Modify: `fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java:527` (the empty stub) + +**Interfaces:** +- Consumes: `Env.getCurrentEnv().getSortedPartitionsCacheManager()` → `NereidsSortedPartitionsCacheManager.get(SupportBinarySearchFilteringPartitions, CatalogRelation)`. +- Produces: `ExternalTable.getSortedPartitionRanges(CatalogRelation)` returns cached ranges for tables that implement `SupportBinarySearchFilteringPartitions`, else `Optional.empty()`. + +- [ ] **Step 1: Write the failing test** + +Add to `NereidsSortedPartitionsCacheManagerExternalTest`: +```java + @Test + public void testGetSortedPartitionRangesDelegates() throws Exception { + // A non-Support table returns empty; a Support table routes through the manager. + // Uses a subclass hook rather than a live catalog to stay unit-scoped. + Assertions.assertTrue( + new org.apache.doris.datasource.ExternalTable() {}.getSortedPartitionRanges(null).isPresent() + == false, + "base ExternalTable (not Support) yields empty"); + } +``` +(If `ExternalTable` is abstract/needs ctor args, replace the anonymous instance with a minimal stub subclass defined in the test that supplies the required no-arg-safe fields; keep the assertion: non-Support ⇒ empty.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: same `-Dtest=NereidsSortedPartitionsCacheManagerExternalTest ...` +Expected: FAIL to compile or assert until the stub is revived to the delegating form (currently it unconditionally returns `Optional.empty()`, so this specific assertion would actually pass — the meaningful failure is Task 5's integration assertion; keep this as a guard that the empty-for-non-Support branch is preserved). + +- [ ] **Step 3: Rewrite the stub** + +Replace `ExternalTable.getSortedPartitionRanges` (`:519-529` region) with: +```java + /** + * Cross-query cache of the pre-built {@link SortedPartitionRanges} for binary-search partition + * pruning. Tables that implement {@link SupportBinarySearchFilteringPartitions} (external MVCC: + * iceberg/paimon) route through the shared {@link NereidsSortedPartitionsCacheManager}, keyed by the + * pinned connector snapshot; others return empty (the caller falls back to building ranges fresh). + */ + public Optional> getSortedPartitionRanges(CatalogRelation scan) { + if (!(this instanceof SupportBinarySearchFilteringPartitions)) { + return Optional.empty(); + } + Optional> cached = Env.getCurrentEnv().getSortedPartitionsCacheManager() + .get((SupportBinarySearchFilteringPartitions) this, scan); + return (Optional) cached; + } +``` +Add imports if missing: `org.apache.doris.catalog.SupportBinarySearchFilteringPartitions`, `org.apache.doris.common.cache.NereidsSortedPartitionsCacheManager`, `org.apache.doris.nereids.trees.plans.algebra.CatalogRelation`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: same command. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalTable.java \ + fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java +git commit -m "[feat](catalog) revive ExternalTable.getSortedPartitionRanges to delegate to NereidsSortedPartitionsCacheManager" +``` + +--- + +### Task 3: Wire the cache into `PruneFileScanPartition`'s ranges chain + +**Files:** +- Modify: `fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java:96-102` + +**Interfaces:** +- Consumes: `externalTable.getSortedPartitionRanges(scan)` (Task 2). +- Produces: ranges preference order `frozen field → cache manager → build-fresh`. + +- [ ] **Step 1: Write the failing test** + +Add to the external test a check that the prune rule prefers the cache. Since driving the full rule needs an FE server, assert the source order via a focused unit on the chain expression is impractical; instead this behavior is covered by the integration Task 5. Mark Step 1 done by adding a code comment TODO-free assertion in Task 5. (No separate failing test here — this task is a 3-line edit validated by Task 5's integration test and by the existing `BinarySearchPartitionInconsistencyTest` staying green.) + +- [ ] **Step 2: Verify current behavior (regression guard)** + +Run: `mvn -f /mnt/disk1/yy/git/doris/fe/pom.xml -pl fe-core -am surefire:test -Dtest=BinarySearchPartitionInconsistencyTest -DfailIfNoTests=false -Dmaven.build.cache.enabled=false` +Expected: PASS (6/6) — this is the #65659 freeze guard we must not break. + +- [ ] **Step 3: Edit the ranges acquisition** + +In `pruneExternalPartitions`, replace the `sortedPartitionRanges` acquisition (`:99-102`): +```java + if (enableBinarySearch && !nameToPartitionItem.isEmpty()) { + sortedPartitionRanges = scan.getSelectedPartitions().sortedPartitionRanges + .or(() -> (Optional) externalTable.getSortedPartitionRanges(scan)) + .or(() -> Optional.ofNullable(SortedPartitionRanges.build(nameToPartitionItem))); + } +``` +(The middle `.or` is new; frozen-field-first and build-fresh-last are unchanged from #65659.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn -f /mnt/disk1/yy/git/doris/fe/pom.xml -pl fe-core -am surefire:test -Dtest=BinarySearchPartitionInconsistencyTest -DfailIfNoTests=false -Dmaven.build.cache.enabled=false` +Expected: PASS (6/6) — behavior unchanged when cache is empty; the new source only adds a hit path. + +- [ ] **Step 5: Commit** + +```bash +git add fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PruneFileScanPartition.java +git commit -m "[feat](catalog) PruneFileScanPartition: consult NereidsSortedPartitionsCacheManager before building ranges" +``` + +--- + +### Task 4: Invalidate the ranges cache on REFRESH TABLE / partition events + +**Files:** +- Modify: `fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java` (the `invalidateTable(catalogId, db, table)` path — same place the schema entry is dropped, `:212-216/:355-363`) + +**Interfaces:** +- Consumes: `Env.getCurrentEnv().getSortedPartitionsCacheManager().invalidate(catalogName, dbName, tableName)` (existing method, `NereidsSortedPartitionsCacheManager:70`). +- Produces: ranges cache dropped whenever the external schema/table cache is invalidated (REFRESH TABLE/CATALOG/DB, HMS events all funnel here). + +- [ ] **Step 1: Write the failing test** + +Add to the external test: +```java + @Test + public void testInvalidateEvictsRanges() throws Exception { + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + // Populate then invalidate by (catalog,db,table); getPartitionCaches() must not contain the key. + // (Populate via a Support table whose getDatabase() returns a stub catalog/db; assert size 0 after invalidate.) + Assertions.assertEquals(0, mgr.getPartitionCaches().estimatedSize(), + "fresh manager is empty; invalidate is a no-op that must not throw"); + mgr.invalidate("ctl", "db", "t"); // must not throw on absent key + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `-Dtest=NereidsSortedPartitionsCacheManagerExternalTest` +Expected: PASS as a guard (invalidate on absent key is safe); the real assertion is that the MgR path calls it — verified by reading the edit. Proceed. + +- [ ] **Step 3: Add the invalidation call** + +In `ExternalMetaCacheMgr.invalidateTable(long catalogId, String dbName, String tblName)` (the method that drops the `"schema"` entry), after the schema-cache invalidation, add: +```java + // Also drop the Nereids sorted-partition-ranges cache for this external table so binary-search + // pruning does not serve ranges older than the refreshed metadata. + ExternalCatalog ctl = (ExternalCatalog) Env.getCurrentEnv().getCatalogMgr().getCatalog(catalogId); + if (ctl != null) { + Env.getCurrentEnv().getSortedPartitionsCacheManager().invalidate(ctl.getName(), dbName, tblName); + } +``` +(Use the catalog/db/table NAMES that `NereidsSortedPartitionsCacheManager` keys on — its `TableIdentifier(catalog, db, table)` matches `catalog.getName()`, `database.getFullName()`, `table.getName()` as used in its `get()`.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `-Dtest=NereidsSortedPartitionsCacheManagerExternalTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalMetaCacheMgr.java \ + fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java +git commit -m "[feat](catalog) drop external SortedPartitionRanges cache on table invalidation" +``` + +--- + +### Task 5: Integration coverage — cache hit, version rebuild, no TOCTOU + +**Files:** +- Modify: `fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java` + +**Interfaces:** +- Consumes: all of Tasks 1–4. + +- [ ] **Step 1: Write the tests** + +Add two tests using a `SupportBinarySearchFilteringPartitions` fake whose `getDatabase()` returns a stub `DatabaseIf` with a stub `CatalogIf` (so `TableIdentifier` builds), populated with a small partition map: +```java + @Test + public void testCacheHitThenRebuildOnVersionChange() throws Exception { + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + FakeExternalTable t = fakeWithDb(); // helper: getDatabase() returns stub ctl/db "ctl"/"db" + t.parts.put("id=1", listItem(1)); + t.parts.put("id=2", listItem(2)); + + t.version = "s1@0"; + SortedPartitionRanges first = mgr.get(t, null).orElse(null); + Assertions.assertNotNull(first, "ranges built and cached at snapshot s1"); + SortedPartitionRanges hit = mgr.get(t, null).orElse(null); + Assertions.assertSame(first, hit, "same snapshot ⇒ cache hit returns the SAME instance"); + + t.version = "s2@0"; // snapshot advanced (ALTER ADD PARTITION) + t.parts.put("id=3", listItem(3)); + SortedPartitionRanges rebuilt = mgr.get(t, null).orElse(null); + Assertions.assertNotSame(first, rebuilt, "version change ⇒ rebuild"); + Assertions.assertEquals(3, rebuilt.sortedPartitions.size(), "rebuilt from the new partition set"); + } + + @Test + public void testRangesConsistentWithOriginMap() throws Exception { + // The cached ranges are built from getOriginPartitions(scan); every range id must be a key of + // that same map — the invariant PruneFileScanPartition.Preconditions relies on (no TOCTOU). + NereidsSortedPartitionsCacheManager mgr = new NereidsSortedPartitionsCacheManager(); + FakeExternalTable t = fakeWithDb(); + t.parts.put("id=1", listItem(1)); + t.parts.put("id=2", listItem(2)); + SortedPartitionRanges r = mgr.get(t, null).orElse(null); + r.sortedPartitions.forEach(p -> + Assertions.assertTrue(t.parts.containsKey(p.id), "every range id ∈ origin map keys")); + } +``` +Provide the `fakeWithDb()` helper and stub `DatabaseIf`/`CatalogIf` returning names `"db"`/`"ctl"` (only `getFullName()`/`getName()`/`getCatalog()` are exercised by `NereidsSortedPartitionsCacheManager.get`). + +- [ ] **Step 2: Run to verify they fail (before Tasks 1–4 wired) / pass (after)** + +Run: `-Dtest=NereidsSortedPartitionsCacheManagerExternalTest` +Expected after Tasks 1–4: PASS (all cases). + +- [ ] **Step 3: Full fe-core sanity + #65659 guard** + +Run: +```bash +mvn -f /mnt/disk1/yy/git/doris/fe/pom.xml install -DskipTests -Dmaven.build.cache.enabled=false +mvn -f /mnt/disk1/yy/git/doris/fe/pom.xml -pl fe-core -am surefire:test \ + -Dtest=NereidsSortedPartitionsCacheManagerExternalTest,BinarySearchPartitionInconsistencyTest \ + -DfailIfNoTests=false -Dmaven.build.cache.enabled=false +``` +Expected: BUILD SUCCESS; both test classes GREEN. + +- [ ] **Step 4: Commit** + +```bash +git add fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java +git commit -m "[test](catalog) external SortedPartitionRanges cache: hit, version-rebuild, origin-map consistency" +``` + +--- + +## e2e (user runs; not in this plan's automated scope) + +- Many-partition iceberg/paimon table: repeated identical query → second query reuses cached ranges (no rebuild); verify via profile/timing. +- `ALTER TABLE ... ADD/DROP PARTITION` then query → new partition set reflected (snapshot advanced ⇒ version token changes ⇒ rebuild). +- iceberg time travel `FOR VERSION AS OF` two snapshots in one session → each keyed independently, no cross-contamination. +- `set enable_binary_search_filtering_partitions=false` → cache bypassed, behavior identical to pre-change. +- `REFRESH TABLE` → ranges cache dropped (Task 4), next query rebuilds. + +## Self-review notes (author) + +- Spec coverage: this plan implements §6 (cache B wiring) + §10 (invalidation) + §8 (no-TOCTOU via origin-map-consistency test) of the design, scoped to iceberg/paimon per Global Constraints. Cache A (§5) and hive/maxcompute generation token (§7) are explicitly out of scope → separate plans. +- The `getPartitionMetaLoadTimeMillis` returns 0 (always-sort); revisit if a connector later exposes a cheap load-time for the skip-sort heuristic. +- `getDatabase()` on the external table must return the real `ExternalDatabase` so `TableIdentifier` keys match invalidation (`ExternalTable.getDatabase():352` already does). 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}"""