diff --git a/fe/fe-connector/README.md b/fe/fe-connector/README.md
index b455e1c0bfcda9..89d1a0fb422070 100644
--- a/fe/fe-connector/README.md
+++ b/fe/fe-connector/README.md
@@ -311,6 +311,42 @@ String, convert inside `of()`, log a warning) explicitly.
`fe-connector-adbc` is the reference implementation of both classes.
+## Snapshot Metadata and Partition Views
+
+The engine must pin a table read before binding columns or building scan ranges.
+Derived metadata caches must describe that same read, including the selected
+snapshot/ref, schema and partition-spec generation. Never obtain a newer table
+independently to build one part of an already pinned read.
+
+Connectors can supply `getPartitionViewVersion` as a cheap generation key for
+an immutable partition view and its engine pruning index. Derive this key from
+the pinned metadata; do not enumerate or hash every partition to calculate it.
+A metadata refresh invalidates the corresponding engine cache, while in-flight
+readers retain their existing view. Query-specific conversions must not mutate
+objects shared by the connector cache.
+
+A connector declaring `supportsNativePartitionPlanning` can defer the engine
+partition view until a consumer actually requires internal partition semantics.
+For Iceberg, `enable_iceberg_internal_partition_pruning` defaults to `false`:
+ordinary scans use native manifest/file pruning. Setting it to `true` enables
+Doris partition pruning. MV planning and partition metadata rules still request
+the full view; deferred metadata must never be treated as an empty table.
+
+`getSqlCacheVersion` supplies a lightweight result-cache version for a pinned
+read without loading its partitions. The version must distinguish table
+identity and all metadata that can change the query result or bound schema.
+The engine records each table reference separately, so reading the same table
+through current, historical and branch/tag references does not collapse their
+cache dependencies. Cache validation resolves those original references again:
+a moved or removed ref, changed schema, expired snapshot or recreated table
+must not reuse an incompatible cached answer.
+
+Within a statement, reuse the frozen table and its resource lease through
+`ConnectorStatementScope`. Its read-only lookup must not load metadata on a
+miss. Relation existence and name listing can reuse this metadata through the
+SPI without source-specific checks in fe-core. This does not extend cache TTLs
+or authorize cross-user reuse of metadata.
+
## Testing and Verification
- **Unit tests** live in each module; build/test recipes are in `AGENTS.md`.
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
index 8d63b2b01588cc..a07db5a2c18d23 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
@@ -55,6 +55,7 @@
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -97,6 +98,13 @@ public interface IcebergCatalogOps {
/** Lists the view names in {@code dbName}; empty when the catalog is not a (view-enabled) ViewCatalog. */
List listViewNames(String dbName);
+ /** Lists relations without requiring callers to subtract and then re-add view names. */
+ default List listRelationNames(String dbName) {
+ LinkedHashSet names = new LinkedHashSet<>(listTableNames(dbName));
+ names.addAll(listViewNames(dbName));
+ return new ArrayList<>(names);
+ }
+
/** Returns {@code true} iff {@code dbName.tableName} exists. */
boolean tableExists(String dbName, String tableName);
@@ -400,6 +408,15 @@ public List listViewNames(String dbName) {
.collect(Collectors.toList()), Collections.emptyList());
}
+ @Override
+ public List listRelationNames(String dbName) {
+ LinkedHashSet names = catalog.listTables(toNamespace(dbName)).stream()
+ .map(TableIdentifier::name)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ names.addAll(listViewNames(dbName));
+ return new ArrayList<>(names);
+ }
+
@Override
public boolean tableExists(String dbName, String tableName) {
return catalog.tableExists(toTableIdentifier(dbName, tableName));
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 9eed1556c26c99..b8b415f6f88033 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
@@ -25,6 +25,7 @@
import org.apache.doris.connector.spi.ConnectorMetadata;
import org.apache.doris.connector.spi.ConnectorPartitionInfo;
import org.apache.doris.connector.spi.ConnectorSession;
+import org.apache.doris.connector.spi.ConnectorStatementScope;
import org.apache.doris.connector.spi.ConnectorStorageContext;
import org.apache.doris.connector.spi.ConnectorTableSchema;
import org.apache.doris.connector.spi.ConnectorTableStatistics;
@@ -45,6 +46,7 @@
import org.apache.doris.connector.spi.handle.WriteOperation;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartitionView;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
import org.apache.doris.connector.spi.pushdown.ConnectorAnd;
import org.apache.doris.connector.spi.pushdown.ConnectorComparison;
@@ -79,6 +81,7 @@
import org.apache.iceberg.SnapshotSummary;
import org.apache.iceberg.SortOrder;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
import org.apache.iceberg.TableOperations;
import org.apache.iceberg.TableProperties;
import org.apache.iceberg.TableScan;
@@ -342,6 +345,17 @@ public List listTableNames(ConnectorSession session, String dbName) {
}
}
+ @Override
+ public List listRelationNames(ConnectorSession session, String dbName) {
+ try {
+ return executeAuthenticated(() -> catalogOps.listRelationNames(dbName));
+ } catch (RuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to list relation names, error message is: " + e.getMessage(), e);
+ }
+ }
+
@Override
public List listViewNames(ConnectorSession session, String dbName) {
// Mirror legacy IcebergMetadataOps.listViewNames: wrap in the auth context; a RuntimeException
@@ -455,8 +469,44 @@ private String loadTableComment(ConnectorSession session, String dbName, String
@Override
public Optional getTableHandle(
ConnectorSession session, String dbName, String tableName) {
+ IcebergTableHandle handle = new IcebergTableHandle(dbName, tableName);
+ if (IcebergStatementScope.hasReadTable(session, dbName, tableName)
+ || (tableCache != null && tableCache.contains(TableIdentifier.of(dbName, tableName)))) {
+ return Optional.of(handle);
+ }
+ // Offline callers cannot retain a cache lease. Pure existence checks use tableExists instead.
+ if (session == null || session.getStatementScope() == ConnectorStatementScope.NONE) {
+ return tableExists(session, dbName, tableName) ? Optional.of(handle) : Optional.empty();
+ }
+ try {
+ // Loading proves existence and pins the same metadata used by schema/snapshot/scan planning.
+ // Borrow through the existing generation-aware cache so concurrent cold readers share one load.
+ executeAuthenticated(() -> resolveTableForRead(session, handle));
+ return Optional.of(handle);
+ } catch (Exception e) {
+ if (ExceptionUtils.getThrowableList(e).stream().anyMatch(NoSuchTableException.class::isInstance)) {
+ return Optional.empty();
+ }
+ throw IcebergExceptionUtils.wrapTableLoadFailure(handle, e,
+ "Failed to load table, error message is:");
+ }
+ }
+
+ @Override
+ public Optional getTableHandleForDdl(
+ ConnectorSession session, String dbName, String tableName) {
+ return tableExists(session, dbName, tableName)
+ ? Optional.of(new IcebergTableHandle(dbName, tableName)) : Optional.empty();
+ }
+
+ @Override
+ public boolean tableExists(
+ ConnectorSession session, String dbName, String tableName) {
+ // Existence checks are used by DDL and must observe out-of-band drops even while a
+ // read-table cache entry or an in-flight reader still owns the previous metadata.
+ // Query planning reuses pinned/cached metadata through getTableHandle instead.
// Mirror legacy IcebergMetadataOps.tableExist: wrap the remote existence check in the auth context
- // (the handle build below is pure — no remote call).
+ // without loading and pinning a table merely to answer a DDL existence check.
boolean exists;
try {
exists = executeAuthenticated(() -> catalogOps.tableExists(dbName, tableName));
@@ -464,17 +514,14 @@ public Optional getTableHandle(
// Preserve Optional.empty even when an auth/catalog layer wraps NoSuchTableException.
if (ExceptionUtils.getThrowableList(e).stream()
.anyMatch(NoSuchTableException.class::isInstance)) {
- return Optional.empty();
+ return false;
}
// Existence checks in several catalogs load metadata internally, so normalize them at the shared
// handle boundary just like explicit table loads.
throw IcebergExceptionUtils.wrapTableLoadFailure(new IcebergTableHandle(dbName, tableName), e,
"Failed to check table exist, error message is:");
}
- if (!exists) {
- return Optional.empty();
- }
- return Optional.of(new IcebergTableHandle(dbName, tableName));
+ return exists;
}
@Override
@@ -2423,6 +2470,32 @@ public boolean supportsCastPredicatePushdown(ConnectorSession session) {
return true;
}
+ @Override
+ public Optional getSqlCacheVersion(
+ ConnectorSession session, ConnectorTableHandle handle) {
+ IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+ try {
+ return executeAuthenticated(() -> {
+ Table table = resolveTableForRead(session, iceHandle);
+ String version = metadataVersion(table, iceHandle).orElseThrow(
+ () -> new UnsupportedOperationException("Iceberg SQL cache requires published table metadata"));
+ long updateMillis = iceHandle.getSnapshotId() < 0 ? 0
+ : table.snapshot(iceHandle.getSnapshotId()).timestampMillis();
+ return Optional.of(new ConnectorSqlCacheVersion(version, updateMillis));
+ });
+ } catch (Exception e) {
+ throw IcebergExceptionUtils.wrapTableLoadFailure(iceHandle, e,
+ "Failed to get iceberg SQL cache version, error message is:");
+ }
+ }
+
+ @Override
+ public boolean supportsNativePartitionPlanning(ConnectorSession session, ConnectorTableHandle handle) {
+ return !((IcebergTableHandle) handle).isSystemTable()
+ && (session == null || !Boolean.parseBoolean(session.getSessionProperties()
+ .getOrDefault("enable_iceberg_internal_partition_pruning", "false")));
+ }
+
// ========== B-2: partition enumeration (MTMV RANGE view + SHOW PARTITIONS) ==========
/**
@@ -2527,6 +2600,39 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH
}
}
+ private static Optional metadataVersion(Table table, IcebergTableHandle handle) {
+ // Custom catalog tables and unpublished metadata cannot supply a stable metadata generation.
+ if (!(table instanceof BaseTable)) {
+ return Optional.empty();
+ }
+ TableMetadata metadata = ((BaseTable) table).operations().current();
+ String location = metadata.metadataFileLocation();
+ if (location == null) {
+ return Optional.empty();
+ }
+ return Optional.of(metadata.uuid() + ":" + handle.getSnapshotId() + ":"
+ + handle.getSchemaId() + ":" + location);
+ }
+
+ @Override
+ public Optional getPartitionViewVersion(ConnectorSession session, ConnectorTableHandle handle) {
+ IcebergTableHandle iceHandle = (IcebergTableHandle) handle;
+ if (!iceHandle.hasSnapshotPin() || session == null
+ || session.getStatementScope() == ConnectorStatementScope.NONE) {
+ // Without a statement-frozen table, metadata could change between version lookup and listing.
+ return Optional.empty();
+ }
+ try {
+ return executeAuthenticated(() -> {
+ Table table = resolveTableForRead(session, iceHandle);
+ return metadataVersion(table, iceHandle);
+ });
+ } catch (Exception e) {
+ throw IcebergExceptionUtils.wrapTableLoadFailure(iceHandle, e,
+ "Failed to resolve iceberg partition view version, error message is:");
+ }
+ }
+
/**
* The physical iceberg partitions of {@code handle} with per-partition value maps, so the generic
* {@code PluginDrivenExternalTable.getNameToPartitionItems} can populate {@code selectedPartitionNum}
@@ -2595,7 +2701,7 @@ private List listPartitionsUncached(
}
private List listPartitions(Table table, IcebergTableHandle iceHandle) {
- return IcebergPartitionUtils.listPartitions(table,
+ return IcebergPartitionUtils.listPartitions(table, iceHandle.getSnapshotId(),
TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache);
}
@@ -2607,12 +2713,13 @@ private List listPartitions(Table table, IcebergTableHan
* snapshot id (or {@code -1} for an empty table — iceberg DOES support MVCC, so it still pins, mirroring
* paimon), and the LATEST schema id (NOT {@code currentSnapshot().schemaId()} — a schema-only change without
* a new snapshot advances the schema while the snapshot's id lags; legacy reads {@code table.schema()
- * .schemaId()}). T08 serves this through the per-catalog {@link IcebergLatestSnapshotCache}: within the TTL
- * a HIT returns the cached pin without re-loading the table (saves the load I/O and keeps the snapshot
- * STABLE across queries — the legacy with-cache catalog). The cached value carries BOTH ids atomically so a
- * schema-only ALTER between two queries cannot skew snapshotId vs schemaId. A disabled cache
- * ({@code meta.cache.iceberg.table.enable=false}, {@code ttl-second <= 0} or {@code capacity=0}) reads live
- * every call (the legacy no-cache catalog).
+ * .schemaId()}). An already frozen statement read takes precedence. With a raw table cache, the pin comes
+ * from that same table generation: its eviction and the separate latest-snapshot cache can occur at
+ * different times, so consulting both could pair a current table with an older snapshot. Catalogs without
+ * a raw table cache use the per-catalog {@link IcebergLatestSnapshotCache}, which keeps a stable pin within
+ * its TTL. The pin carries BOTH ids atomically so a schema-only ALTER cannot skew snapshotId vs schemaId.
+ * A disabled cache ({@code meta.cache.iceberg.table.enable=false}, {@code ttl-second <= 0} or
+ * {@code capacity=0}) reads live every call (the legacy no-cache catalog).
*/
@Override
public Optional beginQuerySnapshot(
@@ -2623,11 +2730,25 @@ public Optional beginQuerySnapshot(
// per-user loadTable authorization) reads live per-user every call, so authorization runs every time (no
// stale-authz window); mirrors resolveTableForRead's tableCache null-fallback. A disabled cache (the table
// entry turned off) is a non-null cache that reads live internally.
- IcebergLatestSnapshotCache.CachedSnapshot pin = latestSnapshotCache != null
- ? latestSnapshotCache.getOrLoad(id, () -> loadLatestSnapshotPin(session, iceHandle))
- : loadLatestSnapshotPin(session, iceHandle);
+ IcebergLatestSnapshotCache.CachedSnapshot pin;
+ if (IcebergStatementScope.hasReadTable(session, iceHandle.getDbName(), iceHandle.getTableName())) {
+ // Handle/schema resolution may have frozen an older generation before another query's REFRESH.
+ // A newer shared pin must not point outside that frozen metadata, nor may this reader publish its
+ // old pin over the refreshed cache entry.
+ pin = latestSnapshotPin(loadTable(session, iceHandle));
+ } else if (tableCache != null) {
+ // The table cache owns this generation. Its capacity eviction must evict the latest pin as well;
+ // the independently sized latestSnapshotCache cannot establish that relationship.
+ pin = loadLatestSnapshotPin(session, iceHandle);
+ } else {
+ pin = latestSnapshotCache != null
+ ? latestSnapshotCache.getOrLoad(id, () -> loadLatestSnapshotPin(session, iceHandle))
+ : loadLatestSnapshotPin(session, iceHandle);
+ }
+ // SQL result-cache identity and column binding must describe the same read generation. The engine
+ // must not combine this snapshot/schema pin with an independently expiring name-keyed schema cache.
ConnectorMvccSnapshot.Builder snapshot = ConnectorMvccSnapshot.builder()
- .snapshotId(pin.snapshotId).schemaId(pin.schemaId);
+ .snapshotId(pin.snapshotId).schemaId(pin.schemaId).retainSchema(true);
if (pin.snapshotId < 0) {
snapshot.property(EMPTY_PARTITION_STYLE_PROPERTY, pin.emptyPartitionStyle.name());
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
index 7c6d929d6031c6..9e34b54279206f 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionUtils.java
@@ -876,10 +876,15 @@ static List listPartitions(Table table) {
/** Cache-aware overload (PERF-02): see {@link #buildMvccPartitionView(Table, long, TableIdentifier,
* IcebergPartitionCache)}. Keyed by the table's CURRENT snapshot id. */
static List listPartitions(Table table, TableIdentifier id, IcebergPartitionCache cache) {
+ return listPartitions(table, -1L, id, cache);
+ }
+
+ static List listPartitions(Table table, long pinnedSnapshotId,
+ TableIdentifier id, IcebergPartitionCache cache) {
if (table.spec().isUnpartitioned()) {
return Collections.emptyList();
}
- Snapshot current = table.currentSnapshot();
+ Snapshot current = pinnedSnapshotId >= 0 ? table.snapshot(pinnedSnapshotId) : table.currentSnapshot();
if (current == null) {
return Collections.emptyList();
}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
index 06919691b1b4b9..6bb44ce3612ed2 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java
@@ -74,6 +74,11 @@ final class IcebergStatementScope {
private IcebergStatementScope() {}
+ /** A frozen read or its live lease proves existence even if REFRESH retires the catalog cache. */
+ static boolean hasReadTable(ConnectorSession session, String dbName, String tableName) {
+ return ConnectorStatementScopes.getIfPresent(session, TABLE_NAMESPACE, dbName, tableName) != null;
+ }
+
/**
* Loads and freezes the iceberg {@link Table} for {@code db.tbl} once per statement. The frozen operations
* prevent another statement's write refresh from changing the metadata generation after slots are bound.
diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
index 5526a52587dcb8..b727259d491fcd 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java
@@ -113,6 +113,11 @@ boolean isEnabled() {
return entry.isEnabled();
}
+ /** Only a retained, unexpired table proves existence; never loads or caches a boolean answer. */
+ boolean contains(TableIdentifier identifier) {
+ return entry.getIfPresent(identifier) != null;
+ }
+
/**
* Returns the cached table for {@code identifier} if present and unexpired, else runs {@code loader} (the
* live remote {@code loadTable}), caches and returns it. When caching is disabled ({@link #isEnabled()} is
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsTest.java
index 5b8f1bfd5e73d2..4c70aa1c32ed9f 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsTest.java
@@ -226,6 +226,29 @@ public void listTableNamesDoesNotFilterWhenRestViewDisabled() {
"listViews must not be called when view filtering is disabled");
}
+ @Test
+ public void relationListingLoadsEachRemoteListOnceAndDeduplicatesViews() {
+ FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog();
+ catalog.tablesByNs.put(Namespace.of("db1"), Arrays.asList("t1", "v1", "t2"));
+ catalog.viewsByNs.put(Namespace.of("db1"), Arrays.asList("v1", "v2"));
+ IcebergCatalogOps ops = ops(catalog, true, false, true, Optional.empty());
+ Assertions.assertEquals(Arrays.asList("t1", "v1", "t2", "v2"), ops.listRelationNames("db1"));
+ Assertions.assertEquals(1, Collections.frequency(catalog.log, "listTables:db1"));
+ Assertions.assertEquals(1, Collections.frequency(catalog.log, "listViews:db1"));
+ catalog.viewsByNs.put(Namespace.of("db1"), Collections.singletonList("v3"));
+ Assertions.assertEquals(Arrays.asList("t1", "v1", "t2", "v3"), ops.listRelationNames("db1"),
+ "the combined listing must not introduce a long-lived view-name cache");
+ }
+
+ @Test
+ public void relationListingDoesNotAccessDisabledRestViews() {
+ FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog();
+ catalog.tablesByNs.put(Namespace.of("db1"), Arrays.asList("t1", "v1"));
+ IcebergCatalogOps ops = ops(catalog, true, false, false, Optional.empty());
+ Assertions.assertEquals(Arrays.asList("t1", "v1"), ops.listRelationNames("db1"));
+ Assertions.assertFalse(catalog.log.contains("listViews:db1"));
+ }
+
// ---------------------------------------------------------------------
// listViewNames / viewExists — the inverse of listTableNames' view subtraction (B0 view SPI)
// ---------------------------------------------------------------------
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java
index 7aa7bef38e2fd0..de4536c64bb862 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataMvccTest.java
@@ -18,6 +18,7 @@
package org.apache.doris.connector.iceberg;
import org.apache.doris.connector.spi.ConnectorColumn;
+import org.apache.doris.connector.spi.ConnectorSession;
import org.apache.doris.connector.spi.ConnectorTableSchema;
import org.apache.doris.connector.spi.DorisConnectorException;
import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
@@ -42,6 +43,7 @@
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -108,6 +110,82 @@ private static IcebergConnectorMetadata metadataFor(Table table, RecordingIceber
return new IcebergConnectorMetadata(ops, IcebergCatalogProperties.of(Collections.emptyMap()), new RecordingConnectorContext());
}
+ @Test
+ public void nativePartitionPlanningDoesNotOptInSystemTables() {
+ Fixture f = fixture();
+ IcebergConnectorMetadata md = metadataFor(f.table, new RecordingIcebergCatalogOps());
+ Assertions.assertTrue(md.supportsNativePartitionPlanning(null, handle()));
+ Assertions.assertFalse(md.supportsNativePartitionPlanning(null,
+ IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1, null, -1)));
+ }
+
+ @Test
+ public void nativePartitionPlanningFollowsInternalPartitionPruningSwitch() {
+ Fixture f = fixture();
+ IcebergConnectorMetadata md = metadataFor(f.table, new RecordingIcebergCatalogOps());
+ String key = "enable_iceberg_internal_partition_pruning";
+ Assertions.assertTrue(md.supportsNativePartitionPlanning(session(Map.of(), Map.of()), handle()));
+ Assertions.assertTrue(md.supportsNativePartitionPlanning(session(Map.of(key, "false"), Map.of()), handle()));
+ Assertions.assertTrue(md.supportsNativePartitionPlanning(session(Map.of(), Map.of(key, "true")), handle()),
+ "catalog properties must not override the session switch");
+ for (String value : List.of("true", "TRUE")) {
+ ConnectorSession session = session(Map.of(key, value), Map.of());
+ Assertions.assertFalse(md.supportsNativePartitionPlanning(session, handle()));
+ Assertions.assertFalse(md.supportsNativePartitionPlanning(session,
+ IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1, null, -1)));
+ }
+ }
+
+ private static ConnectorSession session(Map properties, Map catalogProperties) {
+ return new ConnectorSession() {
+ @Override
+ public String getQueryId() {
+ return "q";
+ }
+
+ @Override
+ public String getUser() {
+ return "u";
+ }
+
+ @Override
+ public String getTimeZone() {
+ return "UTC";
+ }
+
+ @Override
+ public String getLocale() {
+ return "en_US";
+ }
+
+ @Override
+ public long getCatalogId() {
+ return 0;
+ }
+
+ @Override
+ public String getCatalogName() {
+ return "c";
+ }
+
+ @Override
+ public T getProperty(String name, Class type) {
+ String value = properties.getOrDefault(name, catalogProperties.get(name));
+ return value == null ? null : type.cast(value);
+ }
+
+ @Override
+ public Map getCatalogProperties() {
+ return catalogProperties;
+ }
+
+ @Override
+ public Map getSessionProperties() {
+ return properties;
+ }
+ };
+ }
+
private static ConnectorTableHandle handle() {
return new IcebergTableHandle("db1", "t1");
}
@@ -129,6 +207,8 @@ public void beginQuerySnapshotPinsCurrentSnapshotAndLatestSchema() {
// MUTATION: pinning currentSnapshot().schemaId() instead of table.schema().schemaId() would still be
// schemaIdS2 here (same after the latest snapshot), so the load-bearing assertion is "current snapshot".
Assertions.assertTrue(snap.isPresent());
+ Assertions.assertTrue(snap.get().isSchemaRetained(),
+ "SQL cache identity and column binding must use the same pinned schema");
Assertions.assertEquals(f.s2, snap.get().getSnapshotId());
Assertions.assertEquals(f.schemaIdS2, snap.get().getSchemaId());
// The remote load goes through the seam (auth-wrapped).
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
index 204266c1536c66..d20daa98f70052 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataPartitionViewCacheTest.java
@@ -25,6 +25,7 @@
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartition;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartitionView;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
import org.apache.iceberg.DataFiles;
@@ -123,11 +124,71 @@ private static ConnectorMetadataCache> listCache()
return new ConnectorMetadataCache<>("iceberg", "partition_view", Collections.emptyMap());
}
+ @Test
+ public void sqlCacheVersionPinsMetadataAndDetectsSchemaChangesAndRollback() {
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ IcebergConnectorMetadata md = metadataWithMvccCache(ops, mvccCache());
+ IcebergTableHandle first = handle().withSnapshot(f.s1, null, f.schemaId);
+ IcebergTableHandle second = handle().withSnapshot(f.s2, null, f.schemaId);
+ IcebergStatementScopeTest.ScopeSession session = new IcebergStatementScopeTest.ScopeSession(
+ 1L, "cache-query", new TestStatementScope());
+ ConnectorSqlCacheVersion v1 = md.getSqlCacheVersion(session, first).orElseThrow();
+ ConnectorSqlCacheVersion v2 = md.getSqlCacheVersion(session, second).orElseThrow();
+ Assertions.assertNotEquals(v1.getVersion(), v2.getVersion());
+ Assertions.assertEquals(f.table.snapshot(f.s1).timestampMillis(), v1.getUpdateTimeMillis());
+ Assertions.assertEquals(v2.getVersion(), md.getSqlCacheVersion(session,
+ handle().withSnapshot(f.s2, "branch", f.schemaId)).orElseThrow().getVersion());
+ f.table.updateSchema().addColumn("extra", Types.StringType.get()).commit();
+ Assertions.assertEquals(v2.getVersion(), md.getSqlCacheVersion(session, second).orElseThrow().getVersion(),
+ "an existing statement must not observe a later metadata-only commit");
+ IcebergStatementScopeTest.ScopeSession next = new IcebergStatementScopeTest.ScopeSession(
+ 1L, "next-cache-query", new TestStatementScope());
+ IcebergTableHandle evolved = handle().withSnapshot(f.s2, null, f.table.schema().schemaId());
+ Assertions.assertNotEquals(v2.getVersion(), md.getSqlCacheVersion(next, evolved).orElseThrow().getVersion());
+ // Snapshot ids are opaque; a rollback changes the identity even when its commit time is older.
+ f.table.manageSnapshots().rollbackTo(f.s1).commit();
+ IcebergStatementScopeTest.ScopeSession rollback = new IcebergStatementScopeTest.ScopeSession(
+ 1L, "rollback-query", new TestStatementScope());
+ Assertions.assertNotEquals(v2.getVersion(), md.getSqlCacheVersion(rollback,
+ handle().withSnapshot(f.s1, null, f.table.schema().schemaId())).orElseThrow().getVersion());
+ Assertions.assertTrue(ops.log.stream().noneMatch(entry -> entry.startsWith("listPartitions")), ops.log.toString());
+ }
+
private static List mvccNames(Optional view) {
return view.get().getPartitions().stream()
.map(ConnectorMvccPartition::getName).collect(Collectors.toList());
}
+ @Test
+ public void versionTokenAndListUseThePinnedSnapshot() {
+ TwoSnap f = twoSnapshotTable();
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = f.table;
+ IcebergConnectorMetadata md = metadataWithListCache(ops, listCache());
+ IcebergTableHandle first = handle().withSnapshot(f.s1, null, f.schemaId);
+ IcebergTableHandle second = handle().withSnapshot(f.s2, null, f.schemaId);
+ IcebergStatementScopeTest.ScopeSession session = new IcebergStatementScopeTest.ScopeSession(
+ 1L, "version-query", new TestStatementScope());
+ String firstVersion = md.getPartitionViewVersion(session, first).orElseThrow();
+ Assertions.assertEquals(firstVersion, md.getPartitionViewVersion(session,
+ handle().withSnapshot(f.s1, "branch-alias", f.schemaId)).orElseThrow());
+ Assertions.assertNotEquals(firstVersion, md.getPartitionViewVersion(session, second).orElseThrow());
+ Assertions.assertFalse(md.getPartitionViewVersion(session, handle()).isPresent());
+ Assertions.assertEquals(1, md.listPartitions(session, first, Optional.empty()).size());
+ Assertions.assertEquals(2, md.listPartitions(session, second, Optional.empty()).size());
+ // Metadata-only evolution must change the token even without a new data snapshot.
+ f.table.updateSpec().addField("id").commit();
+ Assertions.assertEquals(firstVersion, md.getPartitionViewVersion(session, first).orElseThrow(),
+ "an existing statement must retain its metadata generation");
+ IcebergStatementScopeTest.ScopeSession next = new IcebergStatementScopeTest.ScopeSession(
+ 1L, "next-query", new TestStatementScope());
+ Assertions.assertNotEquals(firstVersion, md.getPartitionViewVersion(next, first).orElseThrow());
+ Assertions.assertFalse(md.getPartitionViewVersion(null, first).isPresent());
+ Assertions.assertEquals(f.s2, f.table.currentSnapshot().snapshotId());
+ }
+
@Test
public void derivedViewWeightsIncludeTailAtEveryPosition() {
ConnectorTableKey key = new ConnectorTableKey("db", "table", 1L, 1L);
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleReuseTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleReuseTest.java
new file mode 100644
index 00000000000000..8a91fce6a5e75e
--- /dev/null
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleReuseTest.java
@@ -0,0 +1,397 @@
+// 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.spi.ConnectorSession;
+import org.apache.doris.connector.spi.ConnectorStatementScope;
+import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
+import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
+
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SupportsDistributedScanPlanning;
+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.lang.reflect.Proxy;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class IcebergTableHandleReuseTest {
+ private static final TableIdentifier ID = TableIdentifier.of("db1", "t1");
+ private static final Schema SCHEMA = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get()));
+
+ private static ConnectorSession session(String query, ConnectorStatementScope scope) {
+ return new IcebergStatementScopeTest.ScopeSession(7, query, scope);
+ }
+
+ private static RecordingIcebergCatalogOps ops() {
+ RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps();
+ ops.table = new FakeIcebergTable("t1", SCHEMA, PartitionSpec.unpartitioned(),
+ "s3://bucket/db1/t1", Collections.emptyMap());
+ ops.tableExists = true;
+ return ops;
+ }
+
+ private static IcebergConnectorMetadata metadata(RecordingIcebergCatalogOps ops, IcebergTableCache cache) {
+ return new IcebergConnectorMetadata(ops, IcebergCatalogProperties.of(Collections.emptyMap()),
+ new RecordingConnectorContext(), new IcebergLatestSnapshotCache(0L, 1), cache);
+ }
+
+ private static long existenceCalls(RecordingIcebergCatalogOps ops) {
+ return ops.log.stream().filter(s -> s.startsWith("tableExists:")).count();
+ }
+
+ @Test
+ void ddlHandleDoesNotRequireLocalScanPlanning() {
+ RecordingIcebergCatalogOps ops = ops();
+ Table serverPlanned = (Table) Proxy.newProxyInstance(Table.class.getClassLoader(),
+ new Class>[] {Table.class, SupportsDistributedScanPlanning.class},
+ (proxy, method, args) -> {
+ if (method.getName().equals("allowDistributedPlanning")) {
+ return false;
+ }
+ throw new AssertionError("DDL must not read scan metadata: " + method.getName());
+ });
+ ops.table = serverPlanned;
+ IcebergConnectorMetadata metadata = metadata(ops, null);
+ ConnectorSession session = session("ddl", new TestStatementScope());
+
+ RuntimeException scanFailure = Assertions.assertThrows(RuntimeException.class,
+ () -> metadata.getTableHandle(session, "db1", "t1"));
+ Assertions.assertTrue(scanFailure.getMessage().contains("server-side scan planning"));
+ Assertions.assertTrue(metadata.tableExists(session, "db1", "t1"));
+ int loads = (int) ops.log.stream().filter(s -> s.startsWith("loadTable:")).count();
+ ConnectorTableHandle handle = metadata.getTableHandleForDdl(session, "db1", "t1").orElseThrow();
+ Assertions.assertEquals(loads, ops.log.stream().filter(s -> s.startsWith("loadTable:")).count());
+ metadata.dropTable(session, handle);
+ Assertions.assertTrue(ops.log.stream().anyMatch(s -> s.startsWith("dropTable:")));
+ }
+
+ @Test
+ void loadedStatementTableProvesExistenceWithoutSharedCache() {
+ RecordingIcebergCatalogOps ops = ops();
+ IcebergConnectorMetadata metadata = metadata(ops, null);
+ TestStatementScope scope = new TestStatementScope();
+ ConnectorSession session = session("q1", scope);
+ ConnectorTableHandle handle = metadata.getTableHandle(session, "db1", "t1").orElseThrow();
+ metadata.getTableSchema(session, handle);
+ ops.tableExistsFailure = new IllegalStateException("must not contact remote after pinning");
+ ConnectorTableHandle again = metadata.getTableHandle(session, "db1", "t1").orElseThrow();
+ Assertions.assertNotSame(handle, again, "scan handles remain independent of snapshot/pushdown refinements");
+ Assertions.assertEquals(0, existenceCalls(ops));
+ ops.loadTableFailure = new IllegalStateException("new statement must load independently");
+ Assertions.assertThrows(RuntimeException.class,
+ () -> metadata.getTableHandle(session("q2", scope), "db1", "t1"));
+ Assertions.assertThrows(RuntimeException.class,
+ () -> metadata.getTableHandle(new IcebergStatementScopeTest.ScopeSession(8, "q1", scope),
+ "db1", "t1"));
+ Assertions.assertThrows(RuntimeException.class,
+ () -> metadata.getTableHandle(session, "db1", "other"));
+ scope.closeAll();
+ }
+
+ @Test
+ void cacheHitAvoidsRemoteAcrossStatementsAndInvalidationKeepsOnlyExistingReader() {
+ AtomicInteger closes = new AtomicInteger();
+ IcebergTableCache cache = new IcebergTableCache(3600, 10, table -> closes::incrementAndGet);
+ RecordingIcebergCatalogOps ops = ops();
+ IcebergConnectorMetadata metadata = metadata(ops, cache);
+ TestStatementScope first = new TestStatementScope();
+ ConnectorSession reader = session("q1", first);
+ metadata.getTableSchema(reader, new IcebergTableHandle("db1", "t1"));
+ Assertions.assertTrue(cache.contains(ID));
+ Assertions.assertTrue(metadata.getTableHandle(session("q2", new TestStatementScope()),
+ "db1", "t1").isPresent());
+ Assertions.assertEquals(0, existenceCalls(ops));
+ cache.invalidate(ID);
+ ops.tableExists = false;
+ ops.throwNoSuchTableOnLoadTable = true;
+ Assertions.assertFalse(cache.contains(ID));
+ Assertions.assertTrue(metadata.getTableHandle(reader, "db1", "t1").isPresent());
+ Assertions.assertTrue(metadata.getTableHandle(session("q3", new TestStatementScope()),
+ "db1", "t1").isEmpty());
+ Assertions.assertEquals(0, closes.get(), "REFRESH cannot close an active reader's metadata/FileIO");
+ first.closeAll();
+ Assertions.assertEquals(1, closes.get());
+ Assertions.assertTrue(metadata.getTableHandle(reader, "db1", "t1").isEmpty());
+ cache.close();
+ }
+
+ @Test
+ void existenceChecksDoNotLoadTablesOrRetainPositiveNegativeOrFailedChecks() {
+ RecordingIcebergCatalogOps ops = ops();
+ IcebergTableCache cache = new IcebergTableCache(3600, 10);
+ IcebergConnectorMetadata metadata = metadata(ops, cache);
+ ConnectorSession session = session("ddl", new TestStatementScope());
+ Assertions.assertTrue(metadata.tableExists(session, "db1", "t1"));
+ ops.tableExists = false;
+ ops.throwNoSuchTableOnLoadTable = true;
+ Assertions.assertFalse(metadata.tableExists(session, "db1", "t1"));
+ ops.tableExists = true; // e.g. CTAS resolves the same target after creation.
+ Assertions.assertTrue(metadata.tableExists(session, "db1", "t1"));
+ ops.tableExistsFailure = new IllegalStateException("permission denied");
+ Assertions.assertThrows(RuntimeException.class,
+ () -> metadata.tableExists(session, "db1", "t1"));
+ ops.tableExistsFailure = null;
+ Assertions.assertTrue(metadata.tableExists(session, "db1", "t1"));
+ Assertions.assertEquals(5, existenceCalls(ops));
+ Assertions.assertFalse(ops.log.stream().anyMatch(s -> s.startsWith("loadTable:")));
+ Assertions.assertEquals(0, cache.size());
+ cache.close();
+ }
+
+ @Test
+ void ddlExistenceRemainsLiveWithCachedAndPinnedReadMetadata() {
+ RecordingIcebergCatalogOps ops = ops();
+ IcebergTableCache cache = new IcebergTableCache(3600, 10);
+ IcebergConnectorMetadata metadata = metadata(ops, cache);
+ TestStatementScope scope = new TestStatementScope();
+ ConnectorSession reader = session("reader", scope);
+ metadata.getTableSchema(reader, new IcebergTableHandle("db1", "t1"));
+ Assertions.assertTrue(cache.contains(ID));
+ ops.tableExists = false;
+ ops.throwNoSuchTableOnLoadTable = true;
+ Assertions.assertFalse(metadata.tableExists(reader, "db1", "t1"));
+ Assertions.assertFalse(metadata.tableExists(session("ddl", new TestStatementScope()), "db1", "t1"));
+ Assertions.assertFalse(metadata.tableExists(null, "db1", "t1"));
+ Assertions.assertTrue(metadata.getTableHandle(reader, "db1", "t1").isPresent(),
+ "the existing reader keeps its frozen metadata independently of live DDL existence");
+ Assertions.assertEquals(3, existenceCalls(ops));
+ scope.closeAll();
+ cache.close();
+ }
+
+ @Test
+ void disabledCacheSharesOnlyTheCurrentStatementAndNoContextNeverRetainsProof() {
+ RecordingIcebergCatalogOps ops = ops();
+ IcebergTableCache cache = new IcebergTableCache(0, 10);
+ IcebergConnectorMetadata metadata = metadata(ops, cache);
+ TestStatementScope scope = new TestStatementScope();
+ ConnectorSession session = session("q1", scope);
+ metadata.getTableSchema(session, new IcebergTableHandle("db1", "t1"));
+ Assertions.assertFalse(cache.contains(ID));
+ ops.tableExists = false;
+ ops.throwNoSuchTableOnLoadTable = true;
+ Assertions.assertTrue(metadata.getTableHandle(session, "db1", "t1").isPresent());
+ Assertions.assertTrue(metadata.getTableHandle(session("q2", new TestStatementScope()),
+ "db1", "t1").isEmpty());
+ Assertions.assertTrue(metadata.getTableHandle(null, "db1", "t1").isEmpty());
+ Assertions.assertTrue(metadata.getTableHandle(session("offline", ConnectorStatementScope.NONE),
+ "db1", "t1").isEmpty());
+ scope.closeAll();
+ cache.close();
+ }
+
+ @Test
+ void refreshRetainsOldSchemaAndRefsWhileNewStatementSeesSchemaAndBranchMovement() {
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("handles", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ Table table = catalog.createTable(ID, SCHEMA);
+ append(table, "first.parquet");
+ long firstSnapshot = table.currentSnapshot().snapshotId();
+ table.manageSnapshots().createBranch("audit", firstSnapshot).createTag("old", firstSnapshot).commit();
+ RecordingIcebergCatalogOps ops = ops();
+ ops.table = table;
+ IcebergTableCache cache = new IcebergTableCache(3600, 10);
+ IcebergConnectorMetadata metadata = metadata(ops, cache);
+ TestStatementScope first = new TestStatementScope();
+ ConnectorSession old = session("q1", first);
+ ConnectorTableHandle oldHandle = metadata.getTableHandle(old, "db1", "t1").orElseThrow();
+ // The cold handle must pin metadata before schema binding, not merely after getTableSchema.
+ table.updateSchema().updateColumn("id", Types.LongType.get()).commit();
+ append(table, "second.parquet");
+ long secondSnapshot = table.currentSnapshot().snapshotId();
+ table.manageSnapshots().replaceBranch("audit", secondSnapshot).commit();
+ cache.invalidate(ID);
+ Assertions.assertTrue(metadata.getTableHandle(old, "db1", "t1").isPresent());
+ Assertions.assertEquals("INT", metadata.getTableSchema(old, oldHandle).getColumns().get(0)
+ .getType().getTypeName());
+ Assertions.assertEquals(firstSnapshot, metadata.resolveTimeTravel(old, oldHandle,
+ ConnectorTimeTravelSpec.branch("audit")).orElseThrow().getSnapshotId());
+ TestStatementScope next = new TestStatementScope();
+ ConnectorSession fresh = session("q2", next);
+ ConnectorTableHandle freshHandle = metadata.getTableHandle(fresh, "db1", "t1").orElseThrow();
+ Assertions.assertEquals("BIGINT", metadata.getTableSchema(fresh, freshHandle).getColumns().get(0)
+ .getType().getTypeName());
+ Assertions.assertEquals(secondSnapshot, metadata.resolveTimeTravel(fresh, freshHandle,
+ ConnectorTimeTravelSpec.branch("audit")).orElseThrow().getSnapshotId());
+ Assertions.assertEquals(firstSnapshot, metadata.resolveTimeTravel(fresh,
+ metadata.getTableHandle(fresh, "db1", "t1").orElseThrow(),
+ ConnectorTimeTravelSpec.tag("old")).orElseThrow().getSnapshotId());
+ Assertions.assertEquals(firstSnapshot, metadata.resolveTimeTravel(fresh, freshHandle,
+ ConnectorTimeTravelSpec.snapshotId(Long.toString(firstSnapshot))).orElseThrow().getSnapshotId());
+ Assertions.assertEquals(0, existenceCalls(ops), "the pinned load proves existence without a HEAD request");
+ first.closeAll();
+ next.closeAll();
+ cache.close();
+ }
+
+ @Test
+ void coldHandleLoadsOnceWithoutExistenceCheckAndMissingTableCanBeCreatedLater() {
+ RecordingIcebergCatalogOps ops = ops();
+ IcebergTableCache cache = new IcebergTableCache(3600, 10);
+ IcebergConnectorMetadata metadata = metadata(ops, cache);
+ TestStatementScope scope = new TestStatementScope();
+ ConnectorSession session = session("cold", scope);
+ ops.throwNoSuchTableOnLoadTable = true;
+ Assertions.assertTrue(metadata.getTableHandle(session, "db1", "t1").isEmpty());
+ ops.throwNoSuchTableOnLoadTable = false;
+ ConnectorTableHandle handle = metadata.getTableHandle(session, "db1", "t1").orElseThrow();
+ metadata.getTableSchema(session, handle);
+ metadata.getTableHandle(session, "db1", "t1").orElseThrow();
+ Assertions.assertEquals(0, existenceCalls(ops));
+ Assertions.assertEquals(2, ops.log.stream().filter(call -> call.startsWith("loadTable:")).count(),
+ "one failed load and one successful load; no negative entry or schema reload");
+ scope.closeAll();
+ cache.close();
+ }
+
+ @Test
+ void coldHandleDoesNotHidePermissionFailureOrCacheItAsMissing() {
+ RecordingIcebergCatalogOps ops = ops();
+ IcebergTableCache cache = new IcebergTableCache(3600, 10);
+ IcebergConnectorMetadata metadata = metadata(ops, cache);
+ TestStatementScope scope = new TestStatementScope();
+ ConnectorSession session = session("denied", scope);
+ ops.loadTableFailure = new org.apache.iceberg.exceptions.ForbiddenException("denied");
+ Assertions.assertThrows(RuntimeException.class, () -> metadata.getTableHandle(session, "db1", "t1"));
+ ops.loadTableFailure = null;
+ Assertions.assertTrue(metadata.getTableHandle(session, "db1", "t1").isPresent());
+ Assertions.assertEquals(0, existenceCalls(ops));
+ scope.closeAll();
+ cache.close();
+ }
+
+ @Test
+ void refreshedSharedSnapshotPinCannotOverrideAnAlreadyFrozenColdHandle() {
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("cold-pin-race", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ Table table = catalog.createTable(ID, SCHEMA);
+ append(table, "first.parquet");
+ long oldSnapshot = table.currentSnapshot().snapshotId();
+ RecordingIcebergCatalogOps ops = ops();
+ ops.table = table;
+ IcebergTableCache cache = new IcebergTableCache(3600, 10);
+ IcebergLatestSnapshotCache snapshots = new IcebergLatestSnapshotCache(3600, 10);
+ IcebergConnectorMetadata metadata = new IcebergConnectorMetadata(ops,
+ IcebergCatalogProperties.of(Collections.emptyMap()), new RecordingConnectorContext(), snapshots, cache);
+ TestStatementScope oldScope = new TestStatementScope();
+ TestStatementScope freshScope = new TestStatementScope();
+ ConnectorSession old = session("old", oldScope);
+ ConnectorSession fresh = session("fresh", freshScope);
+ try {
+ ConnectorTableHandle oldHandle = metadata.getTableHandle(old, "db1", "t1").orElseThrow();
+ table.updateSchema().updateColumn("id", Types.LongType.get()).commit();
+ append(table, "second.parquet");
+ long newSnapshot = table.currentSnapshot().snapshotId();
+ cache.invalidate(ID);
+ snapshots.invalidate(ID);
+ ConnectorTableHandle freshHandle = metadata.getTableHandle(fresh, "db1", "t1").orElseThrow();
+ Assertions.assertEquals(newSnapshot,
+ metadata.beginQuerySnapshot(fresh, freshHandle).orElseThrow().getSnapshotId());
+ snapshots.getOrLoad(ID, () -> new IcebergLatestSnapshotCache.CachedSnapshot(
+ newSnapshot, table.schema().schemaId()));
+ org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot oldPin =
+ metadata.beginQuerySnapshot(old, oldHandle).orElseThrow();
+ Assertions.assertEquals(oldSnapshot, oldPin.getSnapshotId(),
+ "another query's refreshed pin cannot refer to a snapshot missing from the frozen old table");
+ Assertions.assertEquals("INT", metadata.getTableSchema(old, oldHandle, oldPin)
+ .getColumns().get(0).getType().getTypeName());
+ Assertions.assertEquals(newSnapshot, snapshots.getOrLoad(ID,
+ () -> {
+ throw new AssertionError("the refreshed shared pin must remain cached");
+ }).snapshotId);
+ } finally {
+ oldScope.closeAll();
+ freshScope.closeAll();
+ cache.close();
+ }
+ }
+
+ @Test
+ void warmSnapshotPinFollowsRawTableAfterCapacityEviction() {
+ InMemoryCatalog catalog = new InMemoryCatalog();
+ catalog.initialize("eviction-pin", Collections.emptyMap());
+ catalog.createNamespace(Namespace.of("db1"));
+ Table table = catalog.createTable(ID, SCHEMA);
+ append(table, "first.parquet");
+ long firstSnapshot = table.currentSnapshot().snapshotId();
+ TableIdentifier otherId = TableIdentifier.of("db1", "t2");
+ Table other = catalog.createTable(otherId, SCHEMA);
+ RecordingIcebergCatalogOps ops = ops();
+ ops.table = table;
+ IcebergTableCache cache = new IcebergTableCache(3600, 1);
+ IcebergLatestSnapshotCache snapshots = new IcebergLatestSnapshotCache(3600, 1);
+ IcebergConnectorMetadata metadata = new IcebergConnectorMetadata(ops,
+ IcebergCatalogProperties.of(Collections.emptyMap()), new RecordingConnectorContext(), snapshots, cache);
+ try {
+ TestStatementScope coldScope = new TestStatementScope();
+ ConnectorSession cold = session("cold-a", coldScope);
+ ConnectorTableHandle coldHandle = metadata.getTableHandle(cold, "db1", "t1").orElseThrow();
+ Assertions.assertEquals(firstSnapshot,
+ metadata.beginQuerySnapshot(cold, coldHandle).orElseThrow().getSnapshotId());
+ coldScope.closeAll();
+
+ TestStatementScope warmScope = new TestStatementScope();
+ ConnectorSession warm = session("warm-a", warmScope);
+ ConnectorTableHandle warmHandle = metadata.getTableHandle(warm, "db1", "t1").orElseThrow();
+ Assertions.assertEquals(firstSnapshot,
+ metadata.beginQuerySnapshot(warm, warmHandle).orElseThrow().getSnapshotId());
+ warmScope.closeAll();
+
+ ops.table = other;
+ TestStatementScope otherScope = new TestStatementScope();
+ metadata.getTableHandle(session("cold-b", otherScope), "db1", "t2").orElseThrow();
+ otherScope.closeAll();
+ Assertions.assertFalse(cache.contains(ID));
+
+ append(table, "second.parquet");
+ long secondSnapshot = table.currentSnapshot().snapshotId();
+ ops.table = catalog.loadTable(ID);
+ TestStatementScope refreshedScope = new TestStatementScope();
+ ConnectorSession refreshed = session("cold-a-again", refreshedScope);
+ ConnectorTableHandle refreshedHandle = metadata.getTableHandle(refreshed, "db1", "t1").orElseThrow();
+ Assertions.assertEquals(secondSnapshot,
+ metadata.beginQuerySnapshot(refreshed, refreshedHandle).orElseThrow().getSnapshotId());
+ refreshedScope.closeAll();
+
+ TestStatementScope repeatScope = new TestStatementScope();
+ ConnectorSession repeat = session("warm-a-again", repeatScope);
+ ConnectorTableHandle repeatHandle = metadata.getTableHandle(repeat, "db1", "t1").orElseThrow();
+ Assertions.assertEquals(secondSnapshot,
+ metadata.beginQuerySnapshot(repeat, repeatHandle).orElseThrow().getSnapshotId());
+ repeatScope.closeAll();
+ } finally {
+ cache.close();
+ }
+ }
+
+ private static void append(Table table, String path) {
+ table.newAppend().appendFile(DataFiles.builder(table.spec()).withPath(path).withFormat("PARQUET")
+ .withFileSizeInBytes(10).withRecordCount(1).build()).commit();
+ }
+}
diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java
index 599b702e253ebe..0bf50731e5abed 100644
--- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java
+++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java
@@ -43,6 +43,12 @@ public T computeIfAbsent(String key, Supplier loader) {
return (T) cache.computeIfAbsent(key, k -> loader.get());
}
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getIfPresent(String key) {
+ return (T) cache.get(key);
+ }
+
@Override
public T computeIfAbsentForPlanning(String key, Supplier loader) {
T value = computeIfAbsent(key, loader);
diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetadata.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetadata.java
index b22e13194d49ed..f58326302f9276 100644
--- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetadata.java
+++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetadata.java
@@ -20,12 +20,15 @@
import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartitionView;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.connector.spi.mvcc.ConnectorTableFreshness;
import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
import org.apache.doris.connector.spi.pushdown.ConnectorExpression;
import java.io.Closeable;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -57,6 +60,17 @@ public interface ConnectorMetadata extends
ConnectorIdentifierOps,
Closeable {
+ /**
+ * Lists tables and views for the engine's relation-name cache. The default combines the two domains;
+ * connectors with a shared remote listing may override it to avoid fetching either listing twice.
+ * The engine calls this only for connectors declaring SUPPORTS_VIEW.
+ */
+ default List listRelationNames(ConnectorSession session, String dbName) {
+ LinkedHashSet names = new LinkedHashSet<>(listTableNames(session, dbName));
+ names.addAll(listViewNames(session, dbName));
+ return new ArrayList<>(names);
+ }
+
// ──────────────────── MVCC Snapshots ────────────────────
/**
@@ -71,6 +85,27 @@ default Optional beginQuerySnapshot(
return Optional.empty();
}
+ /**
+ * A lightweight SQL result-cache version for the supplied snapshot-pinned handle.
+ * Empty keeps the engine's legacy freshness path. An implementation that cannot identify a generation
+ * reliably must throw to disable caching for that query, rather than return an unsafe constant.
+ * Implementations must use the statement's frozen metadata and must not enumerate files or partitions.
+ */
+ default Optional getSqlCacheVersion(
+ ConnectorSession session, ConnectorTableHandle handle) {
+ return Optional.empty();
+ }
+
+ /**
+ * Whether ordinary scans can plan files from predicates without enumerating engine partitions.
+ * Opting in permits the engine to defer the pinned partition view until a partition-aware consumer
+ * requests it. The connector must honor the pinned handle independently of an engine partition selection.
+ * Materialized-view planning and partition-based query limits may still request the complete view.
+ */
+ default boolean supportsNativePartitionPlanning(ConnectorSession session, ConnectorTableHandle handle) {
+ return false;
+ }
+
/** Whether relation-level options need the statement's latest snapshot as their version fence. */
default boolean usesStatementSnapshotForOptions(
ConnectorSession session, ConnectorTableHandle handle, Map options) {
diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorPartitionListingOps.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorPartitionListingOps.java
index 97aa2e05bd654c..b0864953a626f5 100644
--- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorPartitionListingOps.java
+++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorPartitionListingOps.java
@@ -37,6 +37,18 @@
*/
public interface ConnectorPartitionListingOps {
+ /**
+ * Optional immutable generation of the unfiltered LIST and MVCC partition views for this handle.
+ * Equal tokens within a table must imply identical partition conversion inputs, including freshness
+ * and partition rules. A snapshot-applied handle must enumerate that snapshot, not a live latest.
+ * Resolving the token must not enumerate partitions. The token and subsequent listings must use the
+ * same statement-frozen metadata. Return empty when the connector cannot provide this guarantee.
+ * The engine additionally keys its converted view by column types and session timezone.
+ */
+ default Optional getPartitionViewVersion(ConnectorSession session, ConnectorTableHandle handle) {
+ return Optional.empty();
+ }
+
/**
* Lists all partition display names (e.g., {@code "year=2024/month=01"}).
*
diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScope.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScope.java
index 5f767a9c7e8fbd..2809da1cb1c9f1 100644
--- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScope.java
+++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScope.java
@@ -44,6 +44,11 @@ public interface ConnectorStatementScope {
*/
T computeIfAbsent(String key, Supplier loader);
+ /** Returns an already loaded statement value without loading or retaining anything on a miss. */
+ default T getIfPresent(String key) {
+ return null;
+ }
+
/**
* {@link #computeIfAbsent} for a value only planning reads, such as a memo that lets the identical scans of
* one plan share their planned ranges. The engine drops such values once planning ends ({@link #endPlanning}),
diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScopes.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScopes.java
index 99d39f2e0e57d6..521cb5e369d3ef 100644
--- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScopes.java
+++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStatementScopes.java
@@ -69,8 +69,16 @@ public static T resolveInStatement(ConnectorSession session, String keyNames
if (session == null) {
return loader.get();
}
- String key = keyNamespace + ":" + session.getCatalogId() + ":" + db + ":" + table
- + ":" + session.getQueryId();
- return session.getStatementScope().computeIfAbsent(key, loader);
+ return session.getStatementScope().computeIfAbsent(key(session, keyNamespace, db, table), loader);
+ }
+
+ /** Looks up the same coordinate as resolveInStatement without starting a load. */
+ public static T getIfPresent(ConnectorSession session, String keyNamespace, String db, String table) {
+ return session == null ? null
+ : session.getStatementScope().getIfPresent(key(session, keyNamespace, db, table));
+ }
+
+ private static String key(ConnectorSession session, String keyNamespace, String db, String table) {
+ return keyNamespace + ":" + session.getCatalogId() + ":" + db + ":" + table + ":" + session.getQueryId();
}
}
diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorTableMetadataOps.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorTableMetadataOps.java
index 4d29727e1b84d9..23007fad9dec3c 100644
--- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorTableMetadataOps.java
+++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorTableMetadataOps.java
@@ -70,6 +70,21 @@ default Optional getTableHandle(
return Optional.empty();
}
+ /**
+ * Resolves a handle for DDL after the caller has checked live table existence. Connectors whose read handles
+ * require scan-capable metadata may return an identifier-only handle here. Other connectors retain their
+ * existing handle resolution behavior.
+ */
+ default Optional getTableHandleForDdl(
+ ConnectorSession session, String dbName, String tableName) {
+ return getTableHandle(session, dbName, tableName);
+ }
+
+ /** Checks existence without requiring the caller to retain a planning handle or read metadata. */
+ default boolean tableExists(ConnectorSession session, String dbName, String tableName) {
+ return getTableHandle(session, dbName, tableName).isPresent();
+ }
+
/**
* Lists the system-table names supported for the given base table
* (e.g. ["snapshots", "schemas", "options", "audit_log", "binlog"]).
diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/mvcc/ConnectorSqlCacheVersion.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/mvcc/ConnectorSqlCacheVersion.java
new file mode 100644
index 00000000000000..8d27173534eb60
--- /dev/null
+++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/mvcc/ConnectorSqlCacheVersion.java
@@ -0,0 +1,44 @@
+// 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.spi.mvcc;
+
+import java.util.Objects;
+
+/**
+ * Immutable whole-table SQL result-cache identity and its wall-clock update time.
+ * The identity is compared for equality, never ordered. It must cover data, schema and table recreation,
+ * and describe the same pinned generation as the scan. Reading it must not enumerate files or partitions.
+ * The timestamp is only for the cache's quiet-window eligibility check, never for identity comparisons.
+ */
+public final class ConnectorSqlCacheVersion {
+ private final String version;
+ private final long updateTimeMillis;
+
+ public ConnectorSqlCacheVersion(String version, long updateTimeMillis) {
+ this.version = Objects.requireNonNull(version, "version");
+ this.updateTimeMillis = updateTimeMillis;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public long getUpdateTimeMillis() {
+ return updateTimeMillis;
+ }
+}
diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
index cab3d00cb4b4be..eb5d693d6f57f4 100644
--- a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
+++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
@@ -89,8 +89,9 @@ public void connectorApiMajorTracksTheRecordedSurfaceChange() throws IOException
version.load(in);
}
// Major 20 adds deleteUsingMetadata to the connector write surface; major 21 adds
- // typed filesystem credential descriptions to that surface.
- Assertions.assertEquals("21.0", version.getProperty("api.version"));
+ // typed filesystem credential descriptions and statement-scoped metadata.
+ // Major 22 adds the metadata-only DDL handle path.
+ Assertions.assertEquals("22.0", version.getProperty("api.version"));
}
/** Root entry points plus provider/handle types returned to connector plugins. */
@@ -99,11 +100,14 @@ public void connectorApiMajorTracksTheRecordedSurfaceChange() throws IOException
ConnectorProvider.class,
ConnectorContext.class,
ConnectorSession.class,
+ ConnectorStatementScope.class,
+ ConnectorStatementScopes.class,
Connector.class,
ConnectorColumnHandle.class,
ConnectorColumn.class,
ConnectorTableSchema.class,
org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot.class,
+ org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion.class,
org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot.Builder.class,
ConnectorScanPlanProvider.class,
org.apache.doris.connector.spi.scan.ConnectorScanRange.class,
diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorRelationMetadataDefaultsTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorRelationMetadataDefaultsTest.java
new file mode 100644
index 00000000000000..2997f0dae89b98
--- /dev/null
+++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorRelationMetadataDefaultsTest.java
@@ -0,0 +1,59 @@
+// 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.spi;
+
+import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+class ConnectorRelationMetadataDefaultsTest {
+ @Test
+ void relationNamesMergeBothDomainsWithoutDuplicates() {
+ ConnectorMetadata metadata = new ConnectorMetadata() {
+ @Override
+ public List listTableNames(ConnectorSession session, String dbName) {
+ return Arrays.asList("table", "view");
+ }
+
+ @Override
+ public List listViewNames(ConnectorSession session, String dbName) {
+ return Arrays.asList("view", "other_view");
+ }
+ };
+ Assertions.assertEquals(Arrays.asList("table", "view", "other_view"),
+ metadata.listRelationNames(null, "db"));
+ }
+
+ @Test
+ void existenceDefaultPreservesHandleBasedConnectors() {
+ ConnectorMetadata metadata = new ConnectorMetadata() {
+ @Override
+ public Optional getTableHandle(
+ ConnectorSession session, String dbName, String tableName) {
+ return "exists".equals(tableName) ? Optional.of(new ConnectorTableHandle() {}) : Optional.empty();
+ }
+ };
+ Assertions.assertTrue(metadata.tableExists(null, "db", "exists"));
+ Assertions.assertFalse(metadata.tableExists(null, "db", "missing"));
+ }
+}
diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorStatementScopesTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorStatementScopesTest.java
index e23eaa6fa83e18..1eda0ec9d97f07 100644
--- a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorStatementScopesTest.java
+++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorStatementScopesTest.java
@@ -35,6 +35,23 @@
*/
public class ConnectorStatementScopesTest {
+ @Test
+ public void lookupUsesTheSameCoordinateWithoutLoadingOnMiss() {
+ RecordingMemoScope scope = new RecordingMemoScope();
+ TestSession session = new TestSession(7L, "q1", scope);
+ Assertions.assertNull(ConnectorStatementScopes.getIfPresent(session, "x.table", "db", "t"));
+ Assertions.assertTrue(scope.cache.isEmpty());
+ Object table = ConnectorStatementScopes.resolveInStatement(session, "x.table", "db", "t", Object::new);
+ Assertions.assertSame(table, ConnectorStatementScopes.getIfPresent(session, "x.table", "db", "t"));
+ Assertions.assertNull(ConnectorStatementScopes.getIfPresent(
+ new TestSession(8L, "q1", scope), "x.table", "db", "t"));
+ Assertions.assertNull(ConnectorStatementScopes.getIfPresent(
+ new TestSession(7L, "q2", scope), "x.table", "db", "t"));
+ Assertions.assertNull(ConnectorStatementScopes.getIfPresent(session, "x.table", "other", "t"));
+ Assertions.assertNull(ConnectorStatementScopes.getIfPresent(session, "other.table", "db", "t"));
+ Assertions.assertNull(ConnectorStatementScopes.getIfPresent(null, "x.table", "db", "t"));
+ }
+
@Test
public void sameCoordinateResolvesOncePerStatement() {
// Same (namespace, catalog, db, table, queryId) within one scope -> loader runs once, one shared instance.
@@ -149,6 +166,12 @@ private static final class RecordingMemoScope implements ConnectorStatementScope
private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
private String lastKey;
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getIfPresent(String key) {
+ return (T) cache.get(key);
+ }
+
@Override
@SuppressWarnings("unchecked")
public T computeIfAbsent(String key, Supplier loader) {
diff --git a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt
index 8c48399c75f970..3d65343de22afc 100644
--- a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt
+++ b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-metadata-methods.txt
@@ -36,12 +36,15 @@ getColumnStatistics(org.apache.doris.connector.spi.ConnectorSession,org.apache.d
getDatabase(org.apache.doris.connector.spi.ConnectorSession,java.lang.String)
getMvccPartitionView(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
getPartitionFreshnessMillis(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.lang.String)
+getPartitionViewVersion(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
getPartitionsFreshnessMillis(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.util.List)
+getSqlCacheVersion(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
getSyntheticScanPredicates(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot)
getSysTableHandle(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.lang.String)
getTableComment(org.apache.doris.connector.spi.ConnectorSession,java.lang.String,java.lang.String)
getTableFreshness(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
getTableHandle(org.apache.doris.connector.spi.ConnectorSession,java.lang.String,java.lang.String)
+getTableHandleForDdl(org.apache.doris.connector.spi.ConnectorSession,java.lang.String,java.lang.String)
getTableSchema(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
getTableSchema(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot)
getTableStatistics(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
@@ -52,6 +55,7 @@ listDatabaseNames(org.apache.doris.connector.spi.ConnectorSession)
listFileSizes(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
listPartitionNames(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
listPartitions(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.util.Optional)
+listRelationNames(org.apache.doris.connector.spi.ConnectorSession,java.lang.String)
listSupportedSysTables(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
listTableNames(org.apache.doris.connector.spi.ConnectorSession,java.lang.String)
listViewNames(org.apache.doris.connector.spi.ConnectorSession,java.lang.String)
@@ -69,6 +73,8 @@ resolveTimeTravel(org.apache.doris.connector.spi.ConnectorSession,org.apache.dor
setTableProperties(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.util.Map)
supportsCastPredicatePushdown(org.apache.doris.connector.spi.ConnectorSession)
supportsColumnHandleSnapshotPin(org.apache.doris.connector.spi.ConnectorSession)
+supportsNativePartitionPlanning(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle)
+tableExists(org.apache.doris.connector.spi.ConnectorSession,java.lang.String,java.lang.String)
truncateTable(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.util.List)
usesStatementSnapshotForOptions(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.util.Map)
validateRowLevelDmlMode(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,org.apache.doris.connector.spi.handle.WriteOperation)
diff --git a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
index 81918e6daa1431..556404bb594a59 100644
--- a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
+++ b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
@@ -105,6 +105,14 @@ org.apache.doris.connector.spi.ConnectorSession#getTimeZone():java.lang.String
org.apache.doris.connector.spi.ConnectorSession#getUser():java.lang.String
org.apache.doris.connector.spi.ConnectorSession#isExternalScanTaskReuseEnabled():boolean
org.apache.doris.connector.spi.ConnectorSession#setCurrentTransaction(org.apache.doris.connector.spi.handle.ConnectorTransaction):void
+org.apache.doris.connector.spi.ConnectorStatementScope#closeAll():void
+org.apache.doris.connector.spi.ConnectorStatementScope#computeIfAbsent(java.lang.String,java.util.function.Supplier):java.lang.Object
+org.apache.doris.connector.spi.ConnectorStatementScope#computeIfAbsentForPlanning(java.lang.String,java.util.function.Supplier):java.lang.Object
+org.apache.doris.connector.spi.ConnectorStatementScope#endPlanning():void
+org.apache.doris.connector.spi.ConnectorStatementScope#getIfPresent(java.lang.String):java.lang.Object
+org.apache.doris.connector.spi.ConnectorStatementScope#getOrCreateMetadata(java.lang.String,java.util.function.Supplier):org.apache.doris.connector.spi.ConnectorMetadata
+org.apache.doris.connector.spi.ConnectorStatementScopes#getIfPresent(org.apache.doris.connector.spi.ConnectorSession,java.lang.String,java.lang.String,java.lang.String):java.lang.Object
+org.apache.doris.connector.spi.ConnectorStatementScopes#resolveInStatement(org.apache.doris.connector.spi.ConnectorSession,java.lang.String,java.lang.String,java.lang.String,java.util.function.Supplier):java.lang.Object
org.apache.doris.connector.spi.ConnectorTableSchema#equals(java.lang.Object):boolean
org.apache.doris.connector.spi.ConnectorTableSchema#getColumns():java.util.List
org.apache.doris.connector.spi.ConnectorTableSchema#getProperties():java.util.Map
@@ -149,6 +157,8 @@ org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot$Builder#property(java.
org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot$Builder#retainSchema(boolean):org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot$Builder
org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot$Builder#schemaId(long):org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot$Builder
org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot$Builder#snapshotId(long):org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot$Builder
+org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion#getUpdateTimeMillis():long
+org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion#getVersion():java.lang.String
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#adjustFileCompressType(org.apache.doris.thrift.TFileCompressType):org.apache.doris.thrift.TFileCompressType
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#appendExplainInfo(java.lang.StringBuilder,java.lang.String,java.util.Map):void
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#applyColumnDefaultsOnRead():boolean
diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml
index 1d91dc0c412092..55ea25afcf097f 100644
--- a/fe/fe-connector/pom.xml
+++ b/fe/fe-connector/pom.xml
@@ -55,7 +55,7 @@ under the License.
of the latter two means bumping this property as well (and fe-extension-spi means bumping
all five families).
-->
- 21.0
+ 22.0
diff --git a/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java b/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java
index f68307a3d2ae80..3790912a51a9a2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java
@@ -267,6 +267,15 @@ private void matchSql(SqlBlockRule rule, String originSql, String sqlHash) throw
}
}
+ /**
+ * Partition-aware rules require the existing engine partition view. Conservatively include enabled
+ * user-bound rules too, so rebinding an existing rule during planning cannot bypass its checks.
+ */
+ public boolean hasPartitionMetadataRules() {
+ return nameToSqlBlockRuleMap.values().stream().anyMatch(rule -> rule.getEnable()
+ && (rule.getPartitionNum() > 0 || Boolean.TRUE.equals(rule.getRequirePartitionFilter())));
+ }
+
/**
* Check number whether legal by user.
**/
@@ -312,6 +321,13 @@ void checkLimitations(SqlBlockRule rule, Long partitionNum, Long tabletNum, Long
if (!rule.getEnable()) {
return;
}
+ if (partitionNum < 0 && (rule.getPartitionNum() > 0
+ || Boolean.TRUE.equals(rule.getRequirePartitionFilter()))) {
+ // A partition-aware rule was created after the scan chose native planning. Do not treat an
+ // unknown partition count as zero; a retry plans with the newly installed rule's exact view.
+ throw new AnalysisException("Partition metadata rule changed during planning; retry the query: "
+ + rule.getName());
+ }
if (Boolean.TRUE.equals(rule.getRequirePartitionFilter()) && isPartitionedTable && !hasPartitionPredicate) {
MetricRepo.COUNTER_HIT_SQL_BLOCK_RULE.increase(1L);
throw new AnalysisException("sql hits sql block rule: " + rule.getName() + ", missing partition filter");
diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
index 4b3e9c34109e2d..5c9fb5fb33e995 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
@@ -33,8 +33,11 @@
import org.apache.doris.common.Pair;
import org.apache.doris.common.Status;
import org.apache.doris.common.util.DebugUtil;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.CatalogMgr;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
import org.apache.doris.mtmv.MTMVRelatedTableIf;
import org.apache.doris.nereids.CascadesContext;
import org.apache.doris.nereids.SqlCacheContext;
@@ -42,6 +45,7 @@
import org.apache.doris.nereids.SqlCacheContext.FullColumnName;
import org.apache.doris.nereids.SqlCacheContext.FullTableName;
import org.apache.doris.nereids.SqlCacheContext.ScanTable;
+import org.apache.doris.nereids.SqlCacheContext.TableReference;
import org.apache.doris.nereids.SqlCacheContext.TableVersion;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundVariable;
@@ -67,6 +71,7 @@
import org.apache.doris.qe.StmtExecutor;
import org.apache.doris.qe.cache.CacheAnalyzer;
import org.apache.doris.qe.cache.SqlCache;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
import org.apache.doris.rpc.RpcException;
import com.github.benmanes.caffeine.cache.Cache;
@@ -128,8 +133,8 @@ public void invalidateAboutTable(TableIf tableIf) {
for (Entry kv : sqlCaches.asMap().entrySet()) {
String key = kv.getKey();
SqlCacheContext context = kv.getValue();
- for (Entry nameToVersion : context.getUsedTables().entrySet()) {
- FullTableName tableName = nameToVersion.getKey();
+ for (Entry nameToVersion : context.getUsedTables().entrySet()) {
+ FullTableName tableName = nameToVersion.getKey().tableName;
TableVersion tableVersion = nameToVersion.getValue();
if (tableVersion.id == tableIf.getId()) {
invalidateKeys.add(key);
@@ -343,7 +348,7 @@ private Optional tryParseSql(
if (privilegeChanged(connectContext, env, sqlCacheContext)) {
return invalidateCache(key);
}
- IsChanged tableIsChanged = tablesOrDataChanged(env, sqlCacheContext);
+ IsChanged tableIsChanged = tablesOrDataChanged(env, sqlCacheContext, connectContext.getStatementContext());
if (tableIsChanged.changed) {
if (tableIsChanged.invalidCache) {
return invalidateCache(key);
@@ -429,7 +434,7 @@ private Optional tryParseSql(
}
}
- private IsChanged tablesOrDataChanged(Env env, SqlCacheContext sqlCacheContext) {
+ private IsChanged tablesOrDataChanged(Env env, SqlCacheContext sqlCacheContext, StatementContext statement) {
if (sqlCacheContext.hasUnsupportedTables()) {
return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
}
@@ -437,13 +442,13 @@ private IsChanged tablesOrDataChanged(Env env, SqlCacheContext sqlCacheContext)
// the query maybe scan empty partition of the table, we should check these table version too,
// but the table not exists in sqlCacheContext.getScanTables(), so we need check here.
// check table type and version
- for (Entry scanTable : sqlCacheContext.getUsedTables().entrySet()) {
+ for (Entry scanTable : sqlCacheContext.getUsedTables().entrySet()) {
TableVersion tableVersion = scanTable.getValue();
if (tableVersion.type != TableType.OLAP && tableVersion.type != TableType.MATERIALIZED_VIEW
&& tableVersion.type != TableType.PLUGIN_EXTERNAL_TABLE) {
return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
}
- TableIf tableIf = findTableIf(env, scanTable.getKey());
+ TableIf tableIf = findTableIf(env, scanTable.getKey().tableName);
if (tableIf == null) {
return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
} else if (tableIf.isTemporary()) {
@@ -473,6 +478,21 @@ private IsChanged tablesOrDataChanged(Env env, SqlCacheContext sqlCacheContext)
return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
}
}
+ } else if (tableVersion.sqlCacheVersion != null) {
+ MvccReadSelector selector = scanTable.getKey().selector;
+ Optional pin = Optional.empty();
+ if (!selector.isLatest()) {
+ // Reuse the analyzer's resolver: snapshot existence, timestamp semantics, ref kind,
+ // branch/tag movement and schema selection all remain owned by the existing MVCC path.
+ statement.loadSnapshots(tableIf, selector.getTableSnapshot(), selector.getScanParams());
+ pin = statement.getSnapshot(tableIf, selector.getTableSnapshot(), selector.getScanParams());
+ }
+ Optional current = ((SqlCacheVersionProvider) tableIf)
+ .getSqlCacheVersion(pin);
+ if (!current.isPresent() || !tableVersion.sqlCacheVersion.getVersion()
+ .equals(current.get().getVersion())) {
+ return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
+ }
} else if (tableIf instanceof MTMVRelatedTableIf) {
// External MVCC table (flipped hive/iceberg/paimon/hudi): compare the stored data-version
// token against the live one. OlapTable/MTMV are matched by the OlapTable arm above.
@@ -578,7 +598,8 @@ private boolean dataMaskPoliciesChanged(
*/
private boolean tryLockTables(ConnectContext connectContext, Env env, SqlCacheContext sqlCacheContext) {
StatementContext currentStatementContext = connectContext.getStatementContext();
- for (FullTableName fullTableName : sqlCacheContext.getUsedTables().keySet()) {
+ for (TableReference reference : sqlCacheContext.getUsedTables().keySet()) {
+ FullTableName fullTableName = reference.tableName;
TableIf tableIf = findTableIf(env, fullTableName);
if (tableIf == null) {
return false;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java
index 07de8217d5d15c..e097e08d55c6bc 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java
@@ -62,6 +62,12 @@ public T computeIfAbsent(String key, Supplier loader) {
return (T) cache.computeIfAbsent(key, k -> loader.get());
}
+ @Override
+ @SuppressWarnings("unchecked")
+ public T getIfPresent(String key) {
+ return (T) cache.get(key);
+ }
+
@Override
public T computeIfAbsentForPlanning(String key, Supplier loader) {
T value = computeIfAbsent(key, loader);
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 f6f4045e570285..de53810f544536 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
@@ -32,6 +32,7 @@
import org.apache.doris.datasource.metacache.MetaCacheEntryDef;
import org.apache.doris.datasource.metacache.MetaCacheEntryInvalidation;
import org.apache.doris.datasource.metacache.MetaCacheEntryStats;
+import org.apache.doris.datasource.mvcc.PluginDrivenPartitionView;
import org.apache.doris.fs.FileSystemCache;
import com.github.benmanes.caffeine.cache.stats.CacheStats;
@@ -50,6 +51,7 @@
import java.util.OptionalLong;
import java.util.concurrent.ExecutorService;
import java.util.function.Consumer;
+import java.util.function.Function;
import javax.annotation.Nullable;
/**
@@ -479,6 +481,15 @@ public Optional getSchemaCacheValue(ExternalTable table, Schem
}
}
+ /** The caller resolves connector metadata first; this cache only reuses its FE projection. */
+ public PluginDrivenPartitionView getPartitionView(ExternalTable table, PluginDrivenPartitionView.Key key,
+ Function loader) {
+ long catalogId = table.getCatalog().getId();
+ prepareCatalogByEngine(catalogId, ENGINE_DEFAULT);
+ return engine(ENGINE_DEFAULT).entry(catalogId, "partition_view",
+ PluginDrivenPartitionView.Key.class, PluginDrivenPartitionView.class).get(key, loader);
+ }
+
public FileSystemCache getFsCache() {
return fsCache;
}
@@ -523,10 +534,11 @@ void replaceEngineCachesForTest(List extends ExternalMetaCache> caches) {
* Registered entries:
*
* - {@code schema}: schema-only cache keyed by {@link SchemaCacheKey}
+ * - {@code partition_view}: FE partition items and their sorted pruning index
*
*
- * This class keeps compatibility for generic external engines and routes only schema
- * loading/invalidation. No engine-specific metadata (partitions/files/snapshots) is cached.
+ *
Connector metadata is still loaded by the connector; only source-independent FE projections
+ * are stored here, with the same catalog/database/table invalidation as schemas.
*/
private static class DefaultExternalMetaCache extends ExternalCatalogMetaCache {
DefaultExternalMetaCache(String engine, ExecutorService refreshExecutor) {
@@ -540,6 +552,11 @@ private static class DefaultExternalMetaCache extends ExternalCatalogMetaCache {
MetaCacheEntryInvalidation.forTableIdentity(
key -> key.getNameMapping().getLocalDbName(),
key -> key.getNameMapping().getLocalTblName())));
+ registerEntry(MetaCacheEntryDef.contextualOnly(
+ "partition_view", PluginDrivenPartitionView.Key.class, PluginDrivenPartitionView.class,
+ defaultEntryCacheSpec(), MetaCacheEntryInvalidation.forTableIdentity(
+ PluginDrivenPartitionView.Key::getDatabase, PluginDrivenPartitionView.Key::getTable))
+ .withSizeEstimator(PluginDrivenPartitionView::estimateSize));
}
@Override
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 4c874fe372179d..1a107879cfff53 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
@@ -540,9 +540,10 @@ public boolean supportInternalPartitionPruned(Optional snapshot) {
/**
* 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).
+ * pruning. The default implementation delegates tables implementing
+ * {@link SupportBinarySearchFilteringPartitions} to {@link NereidsSortedPartitionsCacheManager}.
+ * Plugin MVCC tables override this to return the index owned by their pinned partition view;
+ * other tables return empty (the caller falls back to building ranges fresh).
*
* @param scan the catalog relation
* @return sorted partition ranges, or empty if not supported
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCache.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCache.java
index 4a639d8b20259f..e90837d2083678 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCache.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCache.java
@@ -22,8 +22,6 @@
import org.apache.doris.connector.cache.CacheSpec;
import org.apache.doris.connector.cache.MetaCache;
import org.apache.doris.connector.cache.MetaCacheDefinition;
-import org.apache.doris.connector.cache.MetaCacheSizeEstimator;
-import org.apache.doris.connector.cache.MetaCacheSizeEstimators;
import org.apache.doris.datasource.CacheException;
import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.ExternalCatalog;
@@ -31,7 +29,6 @@
import org.apache.doris.datasource.NameMapping;
import org.apache.doris.datasource.SchemaCacheValue;
-import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Maps;
import java.time.Duration;
@@ -292,7 +289,7 @@ private MetaCache newMetaCacheEntry(
Function loader = wrapSchemaValidator(entryDef.getLoader(), entryDef.getValueType());
MetaCacheDefinition.Builder builder = MetaCacheDefinition.builder(
entryDef.getName(), cacheSpec, entryDef.getInvalidation()::scope)
- .sizeEstimator(sizeEstimator(entryDef.getValueType()));
+ .sizeEstimator(entryDef.getSizeEstimator());
if (loader != null) {
builder.loader(loader);
}
@@ -303,14 +300,6 @@ private MetaCache newMetaCacheEntry(
return group.owner().create(builder.build());
}
- /** A whole-value walk of a wide table's schema would exhaust the visit budget, so it goes column by column. */
- @VisibleForTesting
- static MetaCacheSizeEstimator sizeEstimator(Class valueType) {
- return SchemaCacheValue.class.isAssignableFrom(valueType)
- ? MetaCacheSizeEstimators.reflectivePerElement(value -> ((SchemaCacheValue) value).getColumnLists())
- : MetaCacheSizeEstimators.reflective();
- }
-
private Function wrapSchemaValidator(Function loader, Class valueType) {
if (loader == null) {
return null;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/MetaCacheEntryDef.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/MetaCacheEntryDef.java
index 6a081e28871a4c..3d2525f6242eea 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/MetaCacheEntryDef.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/metacache/MetaCacheEntryDef.java
@@ -18,6 +18,9 @@
package org.apache.doris.datasource.metacache;
import org.apache.doris.connector.cache.CacheSpec;
+import org.apache.doris.connector.cache.MetaCacheSizeEstimator;
+import org.apache.doris.connector.cache.MetaCacheSizeEstimators;
+import org.apache.doris.datasource.SchemaCacheValue;
import java.util.Objects;
import java.util.function.Function;
@@ -100,6 +103,7 @@ public final class MetaCacheEntryDef {
* Keeping it on the definition makes each entry's default policy explicit.
*/
private final CacheSpec defaultCacheSpec;
+ private final MetaCacheSizeEstimator sizeEstimator;
private final boolean autoRefresh;
private final boolean contextualOnly;
private final MetaCacheEntryInvalidation invalidation;
@@ -107,6 +111,21 @@ public final class MetaCacheEntryDef {
private MetaCacheEntryDef(String name, Class keyType, Class valueType,
@Nullable Function loader, CacheSpec defaultCacheSpec, boolean autoRefresh, boolean contextualOnly,
MetaCacheEntryInvalidation invalidation) {
+ this(name, keyType, valueType, loader, defaultCacheSpec, autoRefresh, contextualOnly,
+ invalidation, defaultSizeEstimator(valueType));
+ }
+
+ /** A whole-value walk of a wide schema exceeds the visit budget, so account for its columns individually. */
+ static MetaCacheSizeEstimator defaultSizeEstimator(Class valueType) {
+ return SchemaCacheValue.class.isAssignableFrom(valueType)
+ ? MetaCacheSizeEstimators.reflectivePerElement(value -> ((SchemaCacheValue) value).getColumnLists())
+ : MetaCacheSizeEstimators.reflective();
+ }
+
+ private MetaCacheEntryDef(String name, Class keyType, Class valueType,
+ @Nullable Function loader, CacheSpec defaultCacheSpec, boolean autoRefresh, boolean contextualOnly,
+ MetaCacheEntryInvalidation invalidation, MetaCacheSizeEstimator sizeEstimator) {
+ this.sizeEstimator = Objects.requireNonNull(sizeEstimator, "size estimator is required");
this.name = Objects.requireNonNull(name, "entry name is required");
this.keyType = Objects.requireNonNull(keyType, "entry key type is required");
this.valueType = Objects.requireNonNull(valueType, "entry value type is required");
@@ -184,6 +203,16 @@ public static MetaCacheEntryDef contextualOnly(
invalidation);
}
+ /** Preserve entry-specific accounting when creating the catalog cache runtime. */
+ public MetaCacheEntryDef withSizeEstimator(MetaCacheSizeEstimator estimator) {
+ return new MetaCacheEntryDef<>(name, keyType, valueType, loader, defaultCacheSpec, autoRefresh,
+ contextualOnly, invalidation, estimator);
+ }
+
+ public MetaCacheSizeEstimator getSizeEstimator() {
+ return sizeEstimator;
+ }
+
/**
* @return logical entry name.
*/
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccReadSelector.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccReadSelector.java
new file mode 100644
index 00000000000000..02d356a5fe6dca
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccReadSelector.java
@@ -0,0 +1,116 @@
+// 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.datasource.mvcc;
+
+import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.analysis.TableSnapshot;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+
+/**
+ * Immutable read selector shared by statement snapshot lookup and SQL result-cache dependencies.
+ * Stores the original selector, never a prior execution's resolved dynamic parameters. Every resolution
+ * receives fresh copies, so rebinding or PREPARE/EXECUTE cannot mutate a cached dependency.
+ */
+public final class MvccReadSelector {
+ public static final MvccReadSelector LATEST = new MvccReadSelector(Optional.empty(), Optional.empty());
+
+ private final Optional tableSnapshot;
+ private final Optional scanParams;
+ private final String key;
+
+ private MvccReadSelector(Optional tableSnapshot, Optional scanParams) {
+ this.tableSnapshot = tableSnapshot.map(TableSnapshot::new);
+ this.scanParams = scanParams.map(MvccReadSelector::copyScanParams);
+ this.key = versionKeyOf(this.tableSnapshot, this.scanParams);
+ }
+
+ public static MvccReadSelector of(Optional tableSnapshot, Optional scanParams) {
+ return tableSnapshot.isEmpty() && scanParams.isEmpty()
+ ? LATEST : new MvccReadSelector(tableSnapshot, scanParams);
+ }
+
+ public Optional getTableSnapshot() {
+ return tableSnapshot.map(TableSnapshot::new);
+ }
+
+ public Optional getScanParams() {
+ return scanParams.map(MvccReadSelector::copyScanParams);
+ }
+
+ public boolean isLatest() {
+ return key.isEmpty();
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ private static TableScanParams copyScanParams(TableScanParams params) {
+ return new TableScanParams(params.getParamType(), params.getMapParams(), params.getListParams());
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof MvccReadSelector && key.equals(((MvccReadSelector) other).key);
+ }
+
+ @Override
+ public int hashCode() {
+ return key.hashCode();
+ }
+
+ private static String versionKeyOf(Optional tableSnapshot, Optional scanParams) {
+ // Concatenate both selectors (rather than returning on the first) so the key stays injective even
+ // if a reference ever carried BOTH a snapshot and scan params. Today the two are mutually exclusive
+ // (e.g. IcebergUtils.getQuerySpecSnapshot rejects FOR-TIME together with @branch/@tag), so in every
+ // reachable case exactly one part is non-empty and the key is "v:...", "p:...", or "".
+ StringBuilder key = new StringBuilder();
+ if (tableSnapshot.isPresent()) {
+ TableSnapshot ts = tableSnapshot.get();
+ key.append('v');
+ appendVersionKeyPart(key, ts.getType());
+ appendVersionKeyPart(key, ts.getValue());
+ }
+ if (scanParams.isPresent()) {
+ TableScanParams sp = scanParams.get();
+ key.append('p');
+ appendVersionKeyPart(key, sp.getParamType());
+ Map sortedParams = new TreeMap<>(sp.getMapParams());
+ key.append('m').append(sortedParams.size()).append(':');
+ for (Map.Entry entry : sortedParams.entrySet()) {
+ // Length prefixes preserve entry boundaries even when user values contain Map.toString()
+ // delimiters; sorting separately keeps semantically identical maps order-independent.
+ appendVersionKeyPart(key, entry.getKey());
+ appendVersionKeyPart(key, entry.getValue());
+ }
+ key.append('l').append(sp.getListParams().size()).append(':');
+ for (String value : sp.getListParams()) {
+ appendVersionKeyPart(key, value);
+ }
+ }
+ return key.toString();
+ }
+
+ private static void appendVersionKeyPart(StringBuilder key, Object value) {
+ String text = String.valueOf(value);
+ key.append(text.length()).append(':').append(text);
+ }
+}
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 ddcfc805f81ac0..6db3cc933c72ff 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
@@ -21,6 +21,7 @@
import org.apache.doris.analysis.TableScanParams;
import org.apache.doris.analysis.TableSnapshot;
import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.ListPartitionItem;
import org.apache.doris.catalog.MTMV;
import org.apache.doris.catalog.PartitionItem;
@@ -39,6 +40,7 @@
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartition;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartitionView;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.connector.spi.mvcc.ConnectorTableFreshness;
import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
import org.apache.doris.datasource.ExternalCatalog;
@@ -56,8 +58,11 @@
import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot;
import org.apache.doris.mtmv.MTMVSnapshotIf;
import org.apache.doris.mtmv.MTMVTimestampSnapshot;
+import org.apache.doris.nereids.rules.expression.rules.SortedPartitionRanges;
import org.apache.doris.nereids.trees.plans.algebra.CatalogRelation;
import org.apache.doris.nereids.trees.plans.logical.LogicalFileScan;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
@@ -75,6 +80,8 @@
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -93,7 +100,8 @@
* degrades MTMV to conservative over-refresh (the partition never matches its prior snapshot).
*/
public class PluginDrivenMvccExternalTable extends PluginDrivenExternalTable
- implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable, SupportBinarySearchFilteringPartitions {
+ implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable, SupportBinarySearchFilteringPartitions,
+ SqlCacheVersionProvider {
private static final Logger LOG = LogManager.getLogger(PluginDrivenMvccExternalTable.class);
@@ -183,12 +191,38 @@ private PluginDrivenMvccSnapshot materializeLatest(
// Eager pins bypass the schema cache loader, so preserve its mapped-column validation.
pinnedSchema.validateSchema();
}
- // This pin is not in StatementContext yet; ambient schema lookup can see a later generation.
+ if (canDeferPartitionView(metadata, session, pinnedHandle)) {
+ PluginDrivenSchemaCacheValue schema = pinnedSchema != null ? pinnedSchema
+ : (PluginDrivenSchemaCacheValue) getLatestSchemaCacheValue().orElseThrow(
+ () -> new IllegalStateException("Missing schema for pinned table " + getName()));
+ return new PluginDrivenMvccSnapshot(connectorSnapshot, schema,
+ () -> materializePartitionView(metadata, session, pinnedHandle, connectorSnapshot, schema,
+ schema.getPartitionColumns()));
+ }
List partitionColumns = pinnedSchema == null
? getPartitionColumns() : pinnedSchema.getPartitionColumns();
- // Partition counts feed COUNT(*) pushdown and SQL block rules, so even the initial latest
- // materialization must enumerate the same pinned generation that the data scan reads.
+ return materializePartitionView(metadata, session, pinnedHandle, connectorSnapshot,
+ pinnedSchema, partitionColumns);
+ }
+
+ private boolean canDeferPartitionView(ConnectorMetadata metadata, ConnectorSession session,
+ ConnectorTableHandle handle) {
+ ConnectContext ctx = ConnectContext.get();
+ // Candidates are collected before binding/pinning. Keep the existing MV path intact in this first
+ // step, including its partition pruning and compensation. Non-query MTMV callers stay eager too.
+ return ctx != null && ctx.getStatementContext() != null && !ctx.getState().isInternal()
+ && metadata.supportsNativePartitionPlanning(session, handle)
+ && ctx.getStatementContext().getCandidateMTMVs().isEmpty()
+ && !ctx.getEnv().getSqlBlockRuleMgr().hasPartitionMetadataRules();
+ }
+
+ private PluginDrivenMvccSnapshot materializePartitionView(ConnectorMetadata metadata, ConnectorSession session,
+ ConnectorTableHandle pinnedHandle, ConnectorMvccSnapshot connectorSnapshot,
+ PluginDrivenSchemaCacheValue pinnedSchema, List partitionColumns) {
+ // Partition-aware consumers must enumerate the same pinned generation that the data scan reads,
+ // whether this view is requested during binding or loaded later.
ConnectorTableHandle partitionHandle = pinnedHandle;
+ Optional partitionViewVersion = metadata.getPartitionViewVersion(session, pinnedHandle);
Optional viewOpt =
metadata.getMvccPartitionView(session, pinnedHandle);
if (viewOpt.isPresent()) {
@@ -196,7 +230,8 @@ private PluginDrivenMvccSnapshot materializeLatest(
if (connectorSnapshot.getSnapshotId() < 0) {
// A negative query-begin pin is the connector's resolved-empty generation. Falling through to
// a live LIST read would mix partitions committed after the data scan's empty boundary.
- return buildFromRangeView(connectorSnapshot, view, pinnedSchema, partitionColumns);
+ return buildFromRangeView(
+ connectorSnapshot, view, pinnedSchema, partitionColumns, partitionViewVersion);
}
// A non-RANGE (UNPARTITIONED) view is the connector's "not RANGE / not MTMV-range-eligible" verdict
// (iceberg's range view only covers single time-transform specs). If the table nonetheless declares
@@ -209,22 +244,16 @@ private PluginDrivenMvccSnapshot materializeLatest(
// unaffected. Freshness matches the legacy LIST path (timestamps / 0), harmless here because the
// UNPARTITIONED verdict keeps this table out of the snapshot-id MTMV path.
if (view.getStyle() != ConnectorMvccPartitionView.Style.RANGE && !partitionColumns.isEmpty()) {
- Map listItems = Maps.newHashMap();
- Map listLastModifiedMillis = Maps.newHashMap();
- listPartitions(metadata, session, partitionHandle, partitionColumns,
- listItems, listLastModifiedMillis);
- return new PluginDrivenMvccSnapshot(connectorSnapshot, listItems, listLastModifiedMillis,
- pinnedSchema, PartitionType.UNPARTITIONED, false, 0L);
+ PluginDrivenPartitionView viewItems = listPartitions(metadata, session, partitionHandle,
+ partitionColumns);
+ return new PluginDrivenMvccSnapshot(connectorSnapshot, viewItems,
+ pinnedSchema, PartitionType.UNPARTITIONED, false, 0L, 0L);
}
- return buildFromRangeView(connectorSnapshot, view, pinnedSchema, partitionColumns);
+ return buildFromRangeView(connectorSnapshot, view, pinnedSchema, partitionColumns, partitionViewVersion);
}
- Map nameToPartitionItem = Maps.newHashMap();
- Map nameToLastModifiedMillis = Maps.newHashMap();
- listPartitions(metadata, session, partitionHandle, partitionColumns,
- nameToPartitionItem, nameToLastModifiedMillis);
- return new PluginDrivenMvccSnapshot(connectorSnapshot, nameToPartitionItem,
- nameToLastModifiedMillis, pinnedSchema);
+ PluginDrivenPartitionView viewItems = listPartitions(metadata, session, partitionHandle, partitionColumns);
+ return new PluginDrivenMvccSnapshot(connectorSnapshot, viewItems, pinnedSchema);
}
/**
@@ -237,30 +266,50 @@ private PluginDrivenMvccSnapshot materializeLatest(
* per-partition log-and-skip).
*/
private PluginDrivenMvccSnapshot buildFromRangeView(ConnectorMvccSnapshot connectorSnapshot,
- ConnectorMvccPartitionView view, PluginDrivenSchemaCacheValue pinnedSchema, List partitionColumns) {
+ ConnectorMvccPartitionView view, PluginDrivenSchemaCacheValue pinnedSchema, List partitionColumns,
+ Optional version) {
PartitionType partitionType = view.getStyle() == ConnectorMvccPartitionView.Style.RANGE
? PartitionType.RANGE : PartitionType.UNPARTITIONED;
boolean snapshotIdFreshness =
view.getFreshness() == ConnectorMvccPartitionView.Freshness.SNAPSHOT_ID;
+ List types = partitionColumns.stream().map(Column::getType).collect(Collectors.toList());
+ List partitions = view.getStyle() == ConnectorMvccPartitionView.Style.RANGE
+ ? view.getPartitions() : Collections.emptyList();
+ PluginDrivenPartitionView.Key key = version
+ .map(v -> PluginDrivenPartitionView.Key.versioned(getId(), getDbName(), getName(), v, types, true))
+ .orElseGet(() -> PluginDrivenPartitionView.Key.range(
+ getId(), getDbName(), getName(), partitions, types));
+ PluginDrivenPartitionView converted = cachedPartitionView(key, k -> {
+ List frozenTypes = k.frozenTypes();
+ List columns = Lists.newArrayListWithCapacity(partitionColumns.size());
+ for (int i = 0; i < partitionColumns.size(); i++) {
+ columns.add(new Column(partitionColumns.get(i).getName(), frozenTypes.get(i)));
+ }
+ return buildRangePartitionView(version.isPresent() ? partitions : k.rangePartitions(),
+ columns, k.buildSortedRanges());
+ });
+ return new PluginDrivenMvccSnapshot(connectorSnapshot, converted,
+ pinnedSchema, partitionType, snapshotIdFreshness, view.getNewestUpdateMonotonicMarker(),
+ view.getNewestUpdateWallClockMillis());
+ }
+
+ private PluginDrivenPartitionView buildRangePartitionView(List partitions,
+ List partitionColumns, boolean buildSortedRanges) {
Map nameToPartitionItem = Maps.newHashMap();
Map nameToFreshnessValue = Maps.newHashMap();
- if (view.getStyle() == ConnectorMvccPartitionView.Style.RANGE) {
- for (ConnectorMvccPartition partition : view.getPartitions()) {
- try {
- nameToPartitionItem.put(partition.getName(),
- toRangePartitionItem(partition, partitionColumns));
- } catch (AnalysisException e) {
- // Fail loud: a range partition that cannot build is a real metadata error, not a
- // skippable row (legacy loadPartitionInfo lets getPartitionRange throw).
- throw new RuntimeException("Failed to build range partition item for "
- + partition.getName() + ", partitionColumns: " + partitionColumns, e);
- }
- nameToFreshnessValue.put(partition.getName(), partition.getFreshnessValue());
+ for (ConnectorMvccPartition partition : partitions) {
+ try {
+ nameToPartitionItem.put(partition.getName(),
+ toRangePartitionItem(partition, partitionColumns));
+ } catch (AnalysisException e) {
+ // Fail loud: a range partition that cannot build is a real metadata error, not a
+ // skippable row (legacy loadPartitionInfo lets getPartitionRange throw).
+ throw new RuntimeException("Failed to build range partition item for "
+ + partition.getName() + ", partitionColumns: " + partitionColumns, e);
}
+ nameToFreshnessValue.put(partition.getName(), partition.getFreshnessValue());
}
- return new PluginDrivenMvccSnapshot(connectorSnapshot, nameToPartitionItem, nameToFreshnessValue,
- pinnedSchema, partitionType, snapshotIdFreshness, view.getNewestUpdateMonotonicMarker(),
- view.getNewestUpdateWallClockMillis());
+ return new PluginDrivenPartitionView(nameToPartitionItem, nameToFreshnessValue, buildSortedRanges);
}
/**
@@ -296,11 +345,36 @@ private static List toPartitionValues(List bound) {
* Both an opted-in latest pin and explicit time travel must use their retained schema here.
* Unparseable partitions remain logged and skipped so the snapshot can fall back to scan-all.
*/
- private void listPartitions(ConnectorMetadata metadata, ConnectorSession session,
- ConnectorTableHandle handle, List partitionColumns,
- Map nameToPartitionItem, Map nameToLastModifiedMillis) {
+ private PluginDrivenPartitionView listPartitions(ConnectorMetadata metadata, ConnectorSession session,
+ ConnectorTableHandle handle, List partitionColumns) {
List types = partitionColumns.stream().map(Column::getType).collect(Collectors.toList());
- List parts = metadata.listPartitions(session, handle, Optional.empty());
+ Optional version = metadata.getPartitionViewVersion(session, handle);
+ if (version.isPresent()) {
+ PluginDrivenPartitionView.Key key = PluginDrivenPartitionView.Key.versioned(
+ getId(), getDbName(), getName(), version.get(), types, false);
+ return cachedPartitionView(key, k -> buildListPartitionView(
+ metadata.listPartitions(session, handle, Optional.empty()), k.frozenTypes(),
+ k.buildSortedRanges()));
+ }
+ return partitionView(metadata.listPartitions(session, handle, Optional.empty()), types);
+ }
+
+ PluginDrivenPartitionView partitionView(List parts, List types) {
+ PluginDrivenPartitionView.Key key = PluginDrivenPartitionView.Key.list(
+ getId(), getDbName(), getName(), parts, types);
+ return cachedPartitionView(key, k -> buildListPartitionView(
+ k.listPartitions(), k.frozenTypes(), k.buildSortedRanges()));
+ }
+
+ PluginDrivenPartitionView cachedPartitionView(PluginDrivenPartitionView.Key key,
+ Function loader) {
+ return Env.getCurrentEnv().getExtMetaCacheMgr().getPartitionView(this, key, loader);
+ }
+
+ private PluginDrivenPartitionView buildListPartitionView(List parts, List types,
+ boolean buildSortedRanges) {
+ Map nameToPartitionItem = Maps.newHashMapWithExpectedSize(parts.size());
+ Map nameToLastModifiedMillis = Maps.newHashMapWithExpectedSize(parts.size());
for (ConnectorPartitionInfo part : parts) {
String partitionName = part.getPartitionName();
nameToLastModifiedMillis.put(partitionName, part.getLastModifiedMillis());
@@ -327,7 +401,7 @@ private void listPartitions(ConnectorMetadata metadata, ConnectorSession session
part.getOrderedPartitionValues(), part.getPartitionValueNullFlags()));
} catch (Exception e) {
LOG.warn("toListPartitionItem failed, partitionColumns: {}, partitionName: {}",
- partitionColumns, partitionName, e);
+ types, partitionName, e);
}
}
// One line for the listing, next to the per-partition ones above.
@@ -344,8 +418,9 @@ private void listPartitions(ConnectorMetadata metadata, ConnectorSession session
+ "table is reported UNPARTITIONED for this read: {} listed, 0 built, "
+ "typed by {} partition column(s) {}. The connector's partition columns "
+ "and the values it lists do not agree.",
- getDbName(), getName(), parts.size(), types.size(), partitionColumns);
+ getDbName(), getName(), parts.size(), types.size(), types);
}
+ return new PluginDrivenPartitionView(nameToPartitionItem, nameToLastModifiedMillis, buildSortedRanges);
}
private ConnectorMvccSnapshot emptySnapshot() {
@@ -492,12 +567,8 @@ private MvccSnapshot loadSnapshotInternal(
// pin (connectorSnapshot.getProperties()); the scan node's applySnapshot threads them onto the handle.
// Partitions are listed on the BASE handle at latest — the full latest set, identical to the
// normal-read materializeLatest path — NOT a snapshot-pinned handle.
- Map nameToPartitionItem = Maps.newHashMap();
- Map nameToLastModifiedMillis = Maps.newHashMap();
- listPartitions(metadata, session, handle, getPartitionColumns(),
- nameToPartitionItem, nameToLastModifiedMillis);
- return new PluginDrivenMvccSnapshot(connectorSnapshot, nameToPartitionItem,
- nameToLastModifiedMillis, null);
+ PluginDrivenPartitionView viewItems = listPartitions(metadata, session, handle, getPartitionColumns());
+ return new PluginDrivenMvccSnapshot(connectorSnapshot, viewItems, null);
}
// Schema-at-snapshot: thread the pin onto the handle FIRST (applySnapshot), so a BRANCH pin
@@ -525,13 +596,11 @@ private MvccSnapshot loadSnapshotInternal(
// read the pin when listing (listsPartitionsAtSnapshot) gets the real set instead, listed on the
// snapshot-applied handle, and pruning and partition=N/M both become truthful.
if (metadata.listsPartitionsAtSnapshot(session, pinnedHandle)) {
- Map pinnedPartitionItems = Maps.newHashMap();
- Map pinnedLastModifiedMillis = Maps.newHashMap();
// Typed by the PINNED schema, which is the one this snapshot publishes: see listPartitions.
- listPartitions(metadata, session, pinnedHandle, pinnedSchema.getPartitionColumns(),
- pinnedPartitionItems, pinnedLastModifiedMillis);
- return new PluginDrivenMvccSnapshot(connectorSnapshot, pinnedPartitionItems,
- pinnedLastModifiedMillis, pinnedSchema);
+ Supplier loader = () -> new PluginDrivenMvccSnapshot(connectorSnapshot,
+ listPartitions(metadata, session, pinnedHandle, pinnedSchema.getPartitionColumns()), pinnedSchema);
+ return canDeferPartitionView(metadata, session, pinnedHandle)
+ ? new PluginDrivenMvccSnapshot(connectorSnapshot, pinnedSchema, loader) : loader.get();
}
return new PluginDrivenMvccSnapshot(connectorSnapshot,
Collections.emptyMap(), Collections.emptyMap(), pinnedSchema);
@@ -719,6 +788,15 @@ static boolean schemaCacheDisabled(Connector connector) {
// ──────────────────── partition view (snapshot-aware) ────────────────────
+ @Override
+ public LogicalFileScan.SelectedPartitions initSelectedPartitions(Optional snapshot) {
+ PluginDrivenMvccSnapshot pin = getOrMaterialize(snapshot);
+ if (pin.hasDeferredPartitions()) {
+ return LogicalFileScan.SelectedPartitions.NATIVE;
+ }
+ return super.initSelectedPartitions(Optional.of(pin));
+ }
+
@Override
public boolean supportInternalPartitionPruned(Optional snapshot) {
// Non-range connector views enumerate raw transforms and historical specs for counts.
@@ -752,6 +830,9 @@ public PartitionType getPartitionType(Optional snapshot) {
@Override
public List getPartitionColumns(Optional snapshot) {
PluginDrivenMvccSnapshot pin = getOrMaterialize(snapshot);
+ if (pin.hasDeferredPartitions()) {
+ return super.getPartitionColumns(Optional.of(pin));
+ }
if (pin.getPartitionType() != null) {
// Range-view path: do NOT empty the columns here (parity master getIcebergPartitionColumns, which
// always returns the spec columns); UNPARTITIONED is enforced via getPartitionType above.
@@ -806,11 +887,15 @@ public long getPartitionMetaLoadTimeMillis(CatalogRelation scan) {
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.
+ @Override
+ public Optional> getSortedPartitionRanges(CatalogRelation scan) {
+ ConnectContext context = ConnectContext.get();
+ if (context != null && !context.getSessionVariable().enableBinarySearchFilteringPartitions) {
+ return Optional.empty();
+ }
+ // The index belongs to the query's frozen view, not a separately refreshed table-wide cache.
+ return getOrMaterialize(pinnedSnapshot(scan)).getPartitionView().getSortedRanges();
+ }
private Optional pinnedSnapshot(CatalogRelation scan) {
if (scan instanceof LogicalFileScan) {
@@ -981,6 +1066,20 @@ private FreshnessProbe(ConnectorSession session, ConnectorMetadata metadata,
}
}
+ @Override
+ public Optional getSqlCacheVersion(Optional snapshot) {
+ Optional probe = resolveFreshnessProbe();
+ if (!probe.isPresent()) {
+ return Optional.empty();
+ }
+ FreshnessProbe p = probe.get();
+ ConnectorMvccSnapshot pin = snapshot
+ .map(value -> ((PluginDrivenMvccSnapshot) value).getConnectorSnapshot())
+ .orElseGet(() -> p.metadata.beginQuerySnapshot(p.session, p.handle).orElseGet(this::emptySnapshot));
+ ConnectorTableHandle pinnedHandle = p.metadata.applySnapshot(p.session, p.handle, pin);
+ return p.metadata.getSqlCacheVersion(p.session, pinnedHandle);
+ }
+
@Override
public long getNewestUpdateVersionOrTime() {
// Dictionary-update path: always probe LATEST (bypass any context pin), mirroring legacy
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccSnapshot.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccSnapshot.java
index 62922816519d3e..9b0871c86358d6 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccSnapshot.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccSnapshot.java
@@ -22,18 +22,20 @@
import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
import org.apache.doris.datasource.SchemaCacheValue;
+import com.google.common.base.Suppliers;
+
import java.util.Collections;
-import java.util.HashMap;
import java.util.Map;
+import java.util.function.Supplier;
/**
* Generic MVCC snapshot for plugin-driven (connector SPI) tables.
*
* Pins a single point-in-time view of an MVCC-capable connector table for the whole duration of
* a query: the scalar {@link ConnectorMvccSnapshot} (the snapshot-id pin used for reads) plus the
- * materialized partition view listed at that moment. Holding the materialized view here means every
- * MTMV/MvccTable accessor that receives this snapshot reads the SAME partition set with NO extra
- * connector round-trip (single-pin invariant).
+ * partition view for that same generation. Native file planning can defer partition enumeration; the
+ * first partition-aware accessor loads it once using the already pinned handle and schema. All later
+ * accessors share that immutable view, without resolving latest again (single-pin invariant).
*
* Source-agnostic: it carries already-rendered partition names/items and per-partition staleness
* timestamps, so no data-source-specific logic lives in fe-core. Parity with the legacy
@@ -54,9 +56,10 @@
public class PluginDrivenMvccSnapshot implements MvccSnapshot {
private final ConnectorMvccSnapshot connectorSnapshot;
- private final Map nameToPartitionItem;
- private final Map nameToLastModifiedMillis;
+ private final PluginDrivenPartitionView partitionView;
private final SchemaCacheValue pinnedSchema;
+ // Memoized per pin, never a new latest read. Released after loading along with its captured loader.
+ private final Supplier deferredPartitions;
// Range-view path (connector-supplied); null/false/0 on the legacy path so its behavior is byte-unchanged.
private final PartitionType partitionType; // null => legacy LIST/UNPARTITIONED computed from isPartitionInvalid
private final boolean snapshotIdFreshness; // true => getPartitionSnapshot wraps a snapshot id, else a timestamp
@@ -129,13 +132,23 @@ public PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot,
boolean snapshotIdFreshness,
long newestUpdateMonotonicMarker,
long newestUpdateWallClockMillis) {
+ this(connectorSnapshot, new PluginDrivenPartitionView(
+ nameToPartitionItem == null ? Collections.emptyMap() : nameToPartitionItem,
+ nameToFreshnessValue == null ? Collections.emptyMap() : nameToFreshnessValue), pinnedSchema,
+ partitionType, snapshotIdFreshness, newestUpdateMonotonicMarker, newestUpdateWallClockMillis);
+ }
+
+ PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot, PluginDrivenPartitionView partitionView,
+ SchemaCacheValue pinnedSchema) {
+ this(connectorSnapshot, partitionView, pinnedSchema, null, false, 0L, 0L);
+ }
+
+ PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot, PluginDrivenPartitionView partitionView,
+ SchemaCacheValue pinnedSchema, PartitionType partitionType, boolean snapshotIdFreshness,
+ long newestUpdateMonotonicMarker, long newestUpdateWallClockMillis) {
this.connectorSnapshot = connectorSnapshot;
- this.nameToPartitionItem = nameToPartitionItem == null
- ? Collections.emptyMap()
- : Collections.unmodifiableMap(new HashMap<>(nameToPartitionItem));
- this.nameToLastModifiedMillis = nameToFreshnessValue == null
- ? Collections.emptyMap()
- : Collections.unmodifiableMap(new HashMap<>(nameToFreshnessValue));
+ this.partitionView = partitionView;
+ this.deferredPartitions = null;
this.pinnedSchema = pinnedSchema;
this.partitionType = partitionType;
this.snapshotIdFreshness = snapshotIdFreshness;
@@ -143,6 +156,30 @@ public PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot,
this.newestUpdateWallClockMillis = newestUpdateWallClockMillis;
}
+ PluginDrivenMvccSnapshot(ConnectorMvccSnapshot connectorSnapshot, SchemaCacheValue pinnedSchema,
+ Supplier partitionLoader) {
+ this.connectorSnapshot = connectorSnapshot;
+ this.pinnedSchema = pinnedSchema;
+ this.partitionView = null;
+ this.partitionType = null;
+ this.snapshotIdFreshness = false;
+ this.newestUpdateMonotonicMarker = 0L;
+ this.newestUpdateWallClockMillis = 0L;
+ this.deferredPartitions = Suppliers.memoize(partitionLoader::get);
+ }
+
+ public boolean hasDeferredPartitions() {
+ return deferredPartitions != null;
+ }
+
+ private PluginDrivenMvccSnapshot partitionSnapshot() {
+ return hasDeferredPartitions() ? deferredPartitions.get() : this;
+ }
+
+ PluginDrivenPartitionView getPartitionView() {
+ return partitionSnapshot().partitionView;
+ }
+
public ConnectorMvccSnapshot getConnectorSnapshot() {
return connectorSnapshot;
}
@@ -162,11 +199,11 @@ public long getSchemaId() {
}
public Map getNameToPartitionItem() {
- return nameToPartitionItem;
+ return getPartitionView().getItems();
}
public Map getNameToLastModifiedMillis() {
- return nameToLastModifiedMillis;
+ return getPartitionView().getFreshness();
}
/**
@@ -174,7 +211,7 @@ public Map getNameToLastModifiedMillis() {
* on the legacy path (then the caller computes LIST/UNPARTITIONED from {@link #isPartitionInvalid()}).
*/
public PartitionType getPartitionType() {
- return partitionType;
+ return partitionSnapshot().partitionType;
}
/**
@@ -183,7 +220,7 @@ public PartitionType getPartitionType() {
* {@code MTMVTimestampSnapshot} in {@code getPartitionSnapshot}.
*/
public boolean isSnapshotIdFreshness() {
- return snapshotIdFreshness;
+ return partitionSnapshot().snapshotIdFreshness;
}
/**
@@ -192,7 +229,7 @@ public boolean isSnapshotIdFreshness() {
* the dictionary auto-refresh probe compares. Only meaningful when {@link #getPartitionType()} is non-null.
*/
public long getNewestUpdateMonotonicMarker() {
- return newestUpdateMonotonicMarker;
+ return partitionSnapshot().newestUpdateMonotonicMarker;
}
/**
@@ -201,7 +238,7 @@ public long getNewestUpdateMonotonicMarker() {
* the range-view path.
*/
public long getNewestUpdateWallClockMillis() {
- return newestUpdateWallClockMillis;
+ return partitionSnapshot().newestUpdateWallClockMillis;
}
/**
@@ -213,6 +250,6 @@ public long getNewestUpdateWallClockMillis() {
* flagged invalid. Parity with legacy {@code PaimonPartitionInfo.isPartitionInvalid}.
*/
public boolean isPartitionInvalid() {
- return nameToLastModifiedMillis.size() != nameToPartitionItem.size();
+ return getPartitionView().getFreshness().size() != getPartitionView().getItems().size();
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenPartitionView.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenPartitionView.java
new file mode 100644
index 00000000000000..104eaabcfd8bb8
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenPartitionView.java
@@ -0,0 +1,324 @@
+// 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.datasource.mvcc;
+
+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.RangePartitionItem;
+import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.connector.cache.JvmSizeUtils;
+import org.apache.doris.connector.cache.MetaCacheSizeEstimate;
+import org.apache.doris.connector.cache.ReflectiveObjectSizeEstimator;
+import org.apache.doris.connector.spi.ConnectorPartitionInfo;
+import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartition;
+import org.apache.doris.nereids.rules.expression.rules.SortedPartitionRanges;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.thrift.TScalarType;
+import org.apache.doris.thrift.TTypeDesc;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/** One published partition generation: maps and pruning index share the same read-only partition items. */
+public final class PluginDrivenPartitionView {
+ private final ImmutableMap items;
+ private final ImmutableMap freshness;
+ private final SortedPartitionRanges sortedRanges;
+
+ PluginDrivenPartitionView(Map items, Map freshness) {
+ this(items, freshness, true);
+ }
+
+ PluginDrivenPartitionView(Map items, Map freshness,
+ boolean buildSortedRanges) {
+ this.items = ImmutableMap.copyOf(items);
+ this.freshness = ImmutableMap.copyOf(freshness);
+ // Build before cache admission, like HivePartitionValues: the cache accounts for the index
+ // together with its maps, and readers never observe a partially initialized generation.
+ this.sortedRanges = buildSortedRanges && hasComparableKeys(this.items)
+ ? SortedPartitionRanges.build(this.items) : null;
+ }
+
+ // Iceberg display partitions can come from different specs. They remain useful for counts and
+ // display, but different tuple shapes/types do not form one ordered pruning index.
+ private static boolean hasComparableKeys(Map items) {
+ List types = null;
+ for (PartitionItem item : items.values()) {
+ if (item instanceof ListPartitionItem) {
+ for (PartitionKey key : ((ListPartitionItem) item).getItems()) {
+ if (types == null) {
+ types = key.getTypes();
+ } else if (!types.equals(key.getTypes())) {
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ /** Admission-only traversal, bounded per partition rather than per complete table. */
+ public static MetaCacheSizeEstimate estimateSize(Key key, PluginDrivenPartitionView view) {
+ long bytes = JvmSizeUtils.instanceSize(Key.class);
+ bytes = JvmSizeUtils.saturatedAdd(bytes, ReflectiveObjectSizeEstimator.estimateComplete(
+ java.util.Arrays.asList(key.database, key.table, key.types, key.timeZone, key.version)));
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.objectArraySize(key.partitions.size()));
+ for (Object partition : key.partitions) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, ReflectiveObjectSizeEstimator.estimateComplete(partition));
+ }
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.instanceSize(PluginDrivenPartitionView.class));
+ // ImmutableMap tables, entries, and freshness values; shared names/items are counted once below.
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.saturatedMultiply(
+ view.items.size() + (long) view.freshness.size(), 64L));
+ for (Map.Entry entry : view.items.entrySet()) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.stringSize(entry.getKey()));
+ bytes = JvmSizeUtils.saturatedAdd(bytes, estimatePartitionItem(entry.getValue()));
+ }
+ for (String name : view.freshness.keySet()) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.stringSize(name));
+ }
+ if (view.sortedRanges != null) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes,
+ JvmSizeUtils.instanceSize(SortedPartitionRanges.class));
+ bytes = JvmSizeUtils.saturatedAdd(bytes,
+ JvmSizeUtils.objectArraySize(view.sortedRanges.sortedPartitions.size()));
+ bytes = JvmSizeUtils.saturatedAdd(bytes,
+ JvmSizeUtils.objectArraySize(view.sortedRanges.defaultPartitions.size()));
+ // Index ranges keep converted bounds/literals in addition to the shared PartitionItem.
+ for (SortedPartitionRanges.PartitionItemAndRange range : view.sortedRanges.sortedPartitions) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, estimatePartitionItem(range.partitionItem));
+ bytes = JvmSizeUtils.saturatedAdd(bytes, 128L);
+ }
+ bytes = JvmSizeUtils.saturatedAdd(bytes,
+ JvmSizeUtils.saturatedMultiply(view.sortedRanges.defaultPartitions.size(), 32L));
+ }
+ return MetaCacheSizeEstimate.complete(bytes);
+ }
+
+ private static long estimatePartitionItem(PartitionItem item) {
+ long bytes = JvmSizeUtils.instanceSize(item.getClass());
+ if (item instanceof ListPartitionItem) {
+ List keys = ((ListPartitionItem) item).getItems();
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.arrayListSize(keys.size()));
+ for (PartitionKey key : keys) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, estimatePartitionKey(key));
+ }
+ } else {
+ com.google.common.collect.Range range = ((RangePartitionItem) item).getItems();
+ // Range and its two cut objects.
+ bytes = JvmSizeUtils.saturatedAdd(bytes, 96L);
+ if (range.hasLowerBound()) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, estimatePartitionKey(range.lowerEndpoint()));
+ }
+ if (range.hasUpperBound()) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, estimatePartitionKey(range.upperEndpoint()));
+ }
+ }
+ return bytes;
+ }
+
+ private static long estimatePartitionKey(PartitionKey key) {
+ long bytes = JvmSizeUtils.instanceSize(PartitionKey.class);
+ // Value, primitive-type and original-value lists. Overcount shared scalar types/strings instead
+ // of traversing Expr's memoized supplier (which points back at the expression).
+ bytes = JvmSizeUtils.saturatedAdd(bytes,
+ JvmSizeUtils.saturatedMultiply(JvmSizeUtils.arrayListSize(key.getKeys().size()), 3L));
+ for (org.apache.doris.analysis.LiteralExpr value : key.getKeys()) {
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.instanceSize(value.getClass()));
+ bytes = JvmSizeUtils.saturatedAdd(bytes,
+ ReflectiveObjectSizeEstimator.estimateComplete(value.getType()));
+ // Empty child list, memoizing supplier and its bound supplier, and numeric/date payload.
+ bytes = JvmSizeUtils.saturatedAdd(bytes, 128L);
+ bytes = JvmSizeUtils.saturatedAdd(bytes, JvmSizeUtils.stringSize(value.getStringValue()));
+ }
+ return bytes;
+ }
+
+ ImmutableMap getItems() {
+ return items;
+ }
+
+ ImmutableMap getFreshness() {
+ return freshness;
+ }
+
+ Optional> getSortedRanges() {
+ return Optional.ofNullable(sortedRanges);
+ }
+
+ /**
+ * Uses a connector-certified immutable generation when available, without retaining or visiting
+ * partition records. Otherwise matches the actual connector listing rather than an independently
+ * refreshed snapshot token.
+ * LIST records' conversion fields are immutable; compare only those fields, not unrelated
+ * property maps or statistics. This also reuses equal records from uncached connectors.
+ * RANGE bounds are defensively copied because their SPI wrappers can
+ * retain mutable caller lists. Schema types and session timezone also affect literal conversion.
+ */
+ public static final class Key {
+ private final long tableId;
+ private final String database;
+ private final String table;
+ private final List> partitions;
+ private final boolean range;
+ private final List types;
+ private final String timeZone;
+ private final boolean buildSortedRanges;
+ private final int hash;
+ private final String version;
+
+ private Key(long tableId, String database, String table, List> partitions,
+ List types, boolean range) {
+ this(tableId, database, table, partitions, types, range, null);
+ }
+
+ private Key(long tableId, String database, String table, List> partitions,
+ List types, boolean range, String version) {
+ this.version = version;
+ this.tableId = tableId;
+ this.database = database;
+ this.table = table;
+ this.partitions = ImmutableList.copyOf(partitions);
+ this.range = range;
+ this.types = types.stream().map(Type::toThrift).collect(ImmutableList.toImmutableList());
+ ConnectContext context = ConnectContext.get();
+ // Same fallback as TimestampTzLiteral when parsing an unqualified value.
+ this.timeZone = context == null ? "UTC" : context.getSessionVariable().getTimeZone();
+ this.buildSortedRanges = context == null
+ || context.getSessionVariable().enableBinarySearchFilteringPartitions;
+ int recordsHash = 1;
+ for (Object partition : this.partitions) {
+ recordsHash = 31 * recordsHash + (range ? partition.hashCode()
+ : listRecordHash((ConnectorPartitionInfo) partition));
+ }
+ this.hash = Objects.hash(tableId, database, table, range, this.types, timeZone,
+ buildSortedRanges, version, recordsHash);
+ }
+
+ static Key versioned(long tableId, String database, String table,
+ String version, List types, boolean range) {
+ return new Key(tableId, database, table, ImmutableList.of(), types, range,
+ Objects.requireNonNull(version, "version"));
+ }
+
+ static Key list(long tableId, String database, String table,
+ List partitions, List types) {
+ return new Key(tableId, database, table, partitions, types, false);
+ }
+
+ static Key range(long tableId, String database, String table,
+ List partitions, List types) {
+ List frozen = partitions.stream().map(partition -> new ConnectorMvccPartition(
+ partition.getName(), ImmutableList.copyOf(partition.getLowerBound()),
+ ImmutableList.copyOf(partition.getUpperBound()), partition.getFreshnessValue()))
+ .collect(ImmutableList.toImmutableList());
+ return new Key(tableId, database, table, frozen, types, true);
+ }
+
+ public String getDatabase() {
+ return database;
+ }
+
+ public String getTable() {
+ return table;
+ }
+
+ boolean buildSortedRanges() {
+ return buildSortedRanges;
+ }
+
+ @SuppressWarnings("unchecked")
+ List listPartitions() {
+ return (List) partitions;
+ }
+
+ @SuppressWarnings("unchecked")
+ List rangePartitions() {
+ return (List) partitions;
+ }
+
+ List frozenTypes() {
+ return types.stream().map(type -> {
+ // Type.fromThrift does not retain TIMESTAMPTZ scale or VARBINARY length.
+ // Preserve all scalar parameters when freezing the conversion's schema.
+ if (type.getTypes().get(0).isSetScalarType()) {
+ TScalarType scalar = type.getTypes().get(0).getScalarType();
+ return ScalarType.createType(PrimitiveType.fromThrift(scalar.getType()),
+ scalar.getLen(), scalar.getPrecision(), scalar.getScale());
+ }
+ return Type.fromThrift(type);
+ }).collect(ImmutableList.toImmutableList());
+ }
+
+ private static int listRecordHash(ConnectorPartitionInfo partition) {
+ int result = partition.getPartitionName().hashCode();
+ result = 31 * result + partition.getOrderedPartitionValues().hashCode();
+ result = 31 * result + partition.getPartitionValueNullFlags().hashCode();
+ result = 31 * result + partition.getPartitionValueTypes().hashCode();
+ return 31 * result + Long.hashCode(partition.getLastModifiedMillis());
+ }
+
+ private static boolean sameListRecord(ConnectorPartitionInfo left, ConnectorPartitionInfo right) {
+ return left == right || (left.getLastModifiedMillis() == right.getLastModifiedMillis()
+ && left.getPartitionName().equals(right.getPartitionName())
+ && left.getOrderedPartitionValues().equals(right.getOrderedPartitionValues())
+ && left.getPartitionValueNullFlags().equals(right.getPartitionValueNullFlags())
+ && left.getPartitionValueTypes().equals(right.getPartitionValueTypes()));
+ }
+
+ @Override
+ public int hashCode() {
+ return hash;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof Key)) {
+ return false;
+ }
+ Key that = (Key) other;
+ if (hash != that.hash || tableId != that.tableId || range != that.range
+ || buildSortedRanges != that.buildSortedRanges
+ || !Objects.equals(version, that.version)
+ || !Objects.equals(database, that.database) || !Objects.equals(table, that.table)
+ || !types.equals(that.types) || !timeZone.equals(that.timeZone)
+ || partitions.size() != that.partitions.size()) {
+ return false;
+ }
+ for (int i = 0; i < partitions.size(); i++) {
+ if (range ? !partitions.get(i).equals(that.partitions.get(i))
+ : !sameListRecord((ConnectorPartitionInfo) partitions.get(i),
+ (ConnectorPartitionInfo) that.partitions.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java
index 60c2a5422e4334..78039d974896e5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java
@@ -88,7 +88,6 @@
import org.apache.logging.log4j.Logger;
import java.io.IOException;
-import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -494,23 +493,10 @@ protected List listTableNamesFromRemote(SessionContext ctx, String dbNam
ConnectorSession session = buildCrossStatementSession();
try {
ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector);
- List tableNames = metadata.listTableNames(session, dbName);
- // Deliberately the raw field, NOT hasConnectorCapability(): this already runs inside an initialized
- // catalog, so re-entering makeSureInitialized() here would be pointless work on a listing path.
- if (!connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VIEW)) {
- return tableNames;
+ if (connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VIEW)) {
+ return metadata.listRelationNames(session, dbName);
}
- // Mirror legacy IcebergExternalCatalog.listTableNamesFromRemote: for a view-exposing connector
- // (iceberg) SHOW TABLES includes both tables AND views, because the connector's listTableNames
- // subtracts the view names. Re-merge the connector's view names here (the two sets are disjoint
- // by construction, so a plain addAll cannot introduce duplicates).
- List viewNames = metadata.listViewNames(session, dbName);
- if (viewNames.isEmpty()) {
- return tableNames;
- }
- List merged = new ArrayList<>(tableNames);
- merged.addAll(viewNames);
- return merged;
+ return metadata.listTableNames(session, dbName);
} finally {
session.getStatementScope().closeAll();
}
@@ -520,7 +506,7 @@ protected List listTableNamesFromRemote(SessionContext ctx, String dbNam
public boolean tableExist(SessionContext ctx, String dbName, String tblName) {
ConnectorSession session = buildConnectorSession();
return PluginDrivenMetadata.get(session, connector)
- .getTableHandle(session, dbName, tblName).isPresent();
+ .tableExists(session, dbName, tblName);
}
@Override
@@ -664,8 +650,8 @@ public boolean createTable(CreateTableInfo createTableInfo) throws UserException
ConnectorSession session = buildConnectorSession();
ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector);
// Both views matter: name folding can expose a local conflict that the remote lookup does not see.
- boolean remoteExists = metadata.getTableHandle(session, db.getRemoteName(),
- createTableInfo.getTableName()).isPresent();
+ boolean remoteExists = metadata.tableExists(session, db.getRemoteName(),
+ createTableInfo.getTableName());
boolean localExists = db.getTableNullable(localTableName) != null;
if (remoteExists || localExists) {
if (createTableInfo.isIfNotExists()) {
@@ -690,8 +676,8 @@ public boolean createTable(CreateTableInfo createTableInfo) throws UserException
// The probe and create are not atomic. A successful re-probe turns a lost IF NOT EXISTS race into
// the same CTAS-short-circuit result as an initially existing table.
if (createTableInfo.isIfNotExists()
- && metadata.getTableHandle(session, db.getRemoteName(),
- createTableInfo.getTableName()).isPresent()) {
+ && metadata.tableExists(session, db.getRemoteName(),
+ createTableInfo.getTableName())) {
LOG.info("create table[{}.{}.{}] lost the race to a concurrent creator; "
+ "treating as an IF NOT EXISTS no-op", getName(),
createTableInfo.getDbName(), createTableInfo.getTableName());
@@ -857,8 +843,12 @@ public void dropTable(String dbName, String tableName, boolean isView, boolean i
LOG.info("finished to drop view {}.{}.{}", getName(), dbName, tableName);
return;
}
- Optional handle = metadata.getTableHandle(
- session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName());
+ // A query handle may refer to cached read metadata after an out-of-band drop. DDL must
+ // first check current existence so IF EXISTS still performs local cleanup in that case.
+ Optional handle = metadata.tableExists(
+ session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName())
+ ? metadata.getTableHandleForDdl(session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName())
+ : Optional.empty();
// The table is present in the FE cache but may have been dropped out-of-band on the remote
// side; preserve the existing IF EXISTS handling for that case.
if (!handle.isPresent()) {
@@ -1424,7 +1414,7 @@ private ExternalTable checkExternalTable(TableIf dorisTable) {
*/
private ConnectorTableHandle resolveAlterHandle(ExternalTable externalTable, ConnectorSession session,
ConnectorMetadata metadata) throws DdlException {
- Optional handle = metadata.getTableHandle(
+ Optional handle = metadata.getTableHandleForDdl(
session, externalTable.getRemoteDbName(), externalTable.getRemoteName());
if (!handle.isPresent()) {
throw new DdlException("Failed to get table: '" + externalTable.getName()
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
index e569392b6b5432..1808f6d0f4464f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
@@ -395,6 +395,13 @@ private static final class ResolvedScanProvider {
*/
public void setSelectedPartitions(SelectedPartitions selectedPartitions) {
this.selectedPartitions = selectedPartitions;
+ // Streaming starts before getSplits(); publish both exact and unknown counts at this common
+ // injection boundary, including the eager fallback used by partition-aware SQL block rules.
+ long[] partitionCounts = displayPartitionCounts(selectedPartitions);
+ if (partitionCounts != null) {
+ this.selectedPartitionNum = partitionCounts[0];
+ this.totalPartitionNum = partitionCounts[1];
+ }
}
/**
@@ -489,6 +496,9 @@ static long[] displayPartitionCounts(SelectedPartitions selectedPartitions) {
if (selectedPartitions == null || selectedPartitions == SelectedPartitions.NOT_PRUNED) {
return null;
}
+ if (selectedPartitions.isNativePlanning()) {
+ return new long[] {-1, -1};
+ }
return new long[] {
selectedPartitions.selectedPartitions.size(), selectedPartitions.totalPartitionNum};
}
@@ -687,8 +697,12 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) {
// parent's body wholesale (custom TABLE/QUERY/PREDICATES format), so it must re-emit the
// line itself; the counts are populated from the Nereids pruning result in
// getSplits()/startSplit() (see setSelectedPartitions).
- output.append(prefix).append("partition=").append(selectedPartitionNum)
- .append("/").append(totalPartitionNum).append("\n");
+ if (totalPartitionNum < 0) {
+ output.append(prefix).append("partition=unknown (native planning)\n");
+ } else {
+ output.append(prefix).append("partition=").append(selectedPartitionNum)
+ .append("/").append(totalPartitionNum).append("\n");
+ }
// FIX-E / FIX-R3-RESIDUAL (explain gap): the VERBOSE per-backend block (the backends: list,
// per-file "path start/length" lines, and dataFileNum/deleteFileNum/deleteSplitNum) lives in
// the parent FileScanNode but this override does not call super, so re-emit it under the SAME
@@ -1991,6 +2005,11 @@ private boolean computeBatchMode() {
long estimate = onPluginClassLoader(scanProvider, () -> scanProvider.streamingSplitEstimate(
connectorSession, currentHandle, buildRemainingFilter(), countPushdown));
if (estimate >= 0) {
+ // Streaming does not know the final scanned-partition count before dispatch. Partition
+ // rules need that count now; use synchronous planning, which publishes the connector count.
+ if (org.apache.doris.catalog.Env.getCurrentEnv().getSqlBlockRuleMgr().hasPartitionMetadataRules()) {
+ return false;
+ }
streamingSplitEstimate = estimate;
streamingBatch = true;
return true;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
index 38239a084b68b2..1814b7e4c44617 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
@@ -156,8 +156,7 @@ private void markIvmBaselineRebuild(BaseTableInfo baseTableInfo, boolean allPart
public Set getAvailableMTMVs(Set candidateMTMVs, ConnectContext ctx,
boolean forceConsistent, BiPredicate predicate) {
Set res = Sets.newLinkedHashSet();
- Map, Set> queryUsedPartitions = PartitionCompensator.getQueryUsedPartitions(
- ctx.getStatementContext(), new BitSet());
+ Map, Set> queryUsedPartitions = null;
for (MTMV mtmv : candidateMTMVs) {
if (predicate.test(ctx, mtmv)) {
continue;
@@ -165,6 +164,12 @@ public Set getAvailableMTMVs(Set candidateMTMVs, ConnectContext ctx,
if (!mtmv.isUseForRewrite()) {
continue;
}
+ // Collect potentially large partition sets only when a candidate needs validation, once
+ // for all such candidates. Empty or filtered candidate sets need no partition union.
+ if (queryUsedPartitions == null) {
+ queryUsedPartitions = PartitionCompensator.getQueryUsedPartitions(
+ ctx.getStatementContext(), new BitSet());
+ }
if (isMVPartitionValid(mtmv, ctx, forceConsistent, queryUsedPartitions)) {
res.add(mtmv);
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
index df7a2b101224f9..7675aafdf5e0a0 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
@@ -27,7 +27,10 @@
import org.apache.doris.catalog.TableIf;
import org.apache.doris.catalog.TableIf.TableType;
import org.apache.doris.common.Pair;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.datasource.CatalogIf;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
import org.apache.doris.mtmv.MTMVRelatedTableIf;
import org.apache.doris.mysql.FieldInfo;
import org.apache.doris.mysql.privilege.Auth;
@@ -40,6 +43,7 @@
import org.apache.doris.qe.ResultSet;
import org.apache.doris.qe.SessionVariable;
import org.apache.doris.qe.cache.CacheProxy;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
import org.apache.doris.thrift.TUniqueId;
import com.google.common.collect.ImmutableList;
@@ -74,7 +78,7 @@ public class SqlCacheContext {
private volatile long latestPartitionVersion = -1;
private volatile long sumOfPartitionNum = -1;
// value: version of table
- private final Map usedTables = Maps.newLinkedHashMap();
+ private final Map usedTables = Maps.newLinkedHashMap();
// value: ddl sql
private final Map usedViews = Maps.newLinkedHashMap();
// value: usedColumns
@@ -172,6 +176,17 @@ sessionVariable. readAffectQueryResultVariables((k, v) -> {
/** addUsedTable */
public synchronized void addUsedTable(TableIf tableIf) {
+ addUsedTable(tableIf, Optional.empty());
+ }
+
+ /** Record the exact MVCC generation consumed by this relation. */
+ public synchronized void addUsedTable(TableIf tableIf, Optional snapshot) {
+ addUsedTable(tableIf, snapshot, MvccReadSelector.LATEST);
+ }
+
+ /** Record one table reference independently of other versions of the same table. */
+ public synchronized void addUsedTable(TableIf tableIf, Optional snapshot,
+ MvccReadSelector selector) {
if (tableIf == null) {
return;
}
@@ -190,10 +205,19 @@ public synchronized void addUsedTable(TableIf tableIf) {
}
long version = 0;
+ ConnectorSqlCacheVersion sqlCacheVersion = null;
try {
+ if (tableIf instanceof SqlCacheVersionProvider) {
+ sqlCacheVersion = ((SqlCacheVersionProvider) tableIf).getSqlCacheVersion(snapshot).orElse(null);
+ }
+ if (!selector.isLatest() && sqlCacheVersion == null) {
+ // Legacy freshness is table-wide, so it cannot validate a historical/branch dependency.
+ setHasUnsupportedTables(true);
+ return;
+ }
if (tableIf instanceof OlapTable) {
version = ((OlapTable) tableIf).getVisibleVersion();
- } else if (tableIf instanceof MTMVRelatedTableIf) {
+ } else if (sqlCacheVersion == null && tableIf instanceof MTMVRelatedTableIf) {
// Connector-agnostic data-version token (hive: max transient_lastDdlTime; iceberg/paimon:
// monotonic snapshot version). OlapTable is handled above; MTMV is an OlapTable too, so this
// arm only catches external MVCC tables (flipped hive/iceberg/paimon/hudi).
@@ -212,11 +236,12 @@ public synchronized void addUsedTable(TableIf tableIf) {
}
usedTables.put(
- new FullTableName(database.getCatalog().getName(), database.getFullName(), tableIf.getName()),
+ new TableReference(new FullTableName(database.getCatalog().getName(),
+ database.getFullName(), tableIf.getName()), selector),
new TableVersion(
tableIf.getId(),
version,
- tableIf.getType()
+ tableIf.getType(), sqlCacheVersion
)
);
}
@@ -362,7 +387,7 @@ public void setCacheProxy(CacheProxy cacheProxy) {
this.cacheProxy = cacheProxy;
}
- public Map getUsedTables() {
+ public Map getUsedTables() {
return Collections.unmodifiableMap(usedTables);
}
@@ -446,15 +471,18 @@ public PUniqueId getOrComputeCacheKeyMd5(SessionVariable sessionVariable) {
public synchronized PUniqueId doComputeCacheKeyMd5(
Set usedVariables, SessionVariable sessionVariable) {
StringBuilder cacheKey = new StringBuilder(NereidsParser.removeCommentAndTrimBlank(originSql.trim()));
- for (Entry entry : usedTables.entrySet()) {
+ for (Entry entry : usedTables.entrySet()) {
TableVersion tableVersion = entry.getValue();
- FullTableName tableName = entry.getKey();
+ TableReference reference = entry.getKey();
+ String selectorKey = reference.selector.getKey();
cacheKey.append("|")
- .append(tableName)
+ .append(reference.tableName)
+ .append("#").append(selectorKey.length()).append(":").append(selectorKey)
.append("=")
.append(tableVersion.id)
.append("@")
- .append(tableVersion.version);
+ .append(tableVersion.sqlCacheVersion == null ? Long.toString(tableVersion.version)
+ : "sql-cache:" + tableVersion.sqlCacheVersion.getVersion());
}
for (Entry entry : usedViews.entrySet()) {
cacheKey.append("|")
@@ -590,6 +618,14 @@ public void addScanPartition(Long partitionId) {
}
}
+ /** A table dependency identified by the original read selector, not by an alias or resolved snapshot. */
+ @lombok.Data
+ @lombok.AllArgsConstructor
+ public static class TableReference {
+ public final FullTableName tableName;
+ public final MvccReadSelector selector;
+ }
+
/** TableVersion */
@lombok.Data
@lombok.AllArgsConstructor
@@ -597,6 +633,11 @@ public static class TableVersion {
public final long id;
public final long version;
public final TableType type;
+ public final ConnectorSqlCacheVersion sqlCacheVersion;
+
+ public TableVersion(long id, long version, TableType type) {
+ this(id, version, type, null);
+ }
}
/** CacheKeyType */
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index cd71830ed64f79..80bd559d339b6a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
@@ -35,6 +35,7 @@
import org.apache.doris.connector.spi.ConnectorStatementScope;
import org.apache.doris.connector.spi.handle.ConnectorTransaction;
import org.apache.doris.datasource.ExternalTable;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
import org.apache.doris.datasource.mvcc.MvccSnapshot;
import org.apache.doris.datasource.mvcc.MvccTable;
import org.apache.doris.datasource.mvcc.MvccTableInfo;
@@ -83,6 +84,7 @@
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.base.Throwables;
+import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
@@ -337,9 +339,10 @@ public enum TableFrom {
// if greater than 0 means the duration has used
private long materializedViewRewriteDuration = 0L;
- // Record used table and it's used partitions
+ // Records are consumed by iteration/union, not membership lookup. List values avoid hashing every
+ // partition name inside Pair when recording a scan; repeated records do not change the union.
private final Multimap, Pair>> tableUsedPartitionNameMap =
- HashMultimap.create();
+ ArrayListMultimap.create();
// Record query common table id to relation id mapping, this is used for mv rewrite
private final Multimap commonTableIdToRelationIdToMap = HashMultimap.create();
@@ -1456,40 +1459,7 @@ public Optional getSnapshot(TableIf tableIf, Optional tableSnapshot,
Optional scanParams) {
- // Concatenate both selectors (rather than returning on the first) so the key stays injective even
- // if a reference ever carried BOTH a snapshot and scan params. Today the two are mutually exclusive
- // (e.g. IcebergUtils.getQuerySpecSnapshot rejects FOR-TIME together with @branch/@tag), so in every
- // reachable case exactly one part is non-empty and the key is "v:...", "p:...", or "".
- StringBuilder key = new StringBuilder();
- if (tableSnapshot != null && tableSnapshot.isPresent()) {
- TableSnapshot ts = tableSnapshot.get();
- key.append('v');
- appendVersionKeyPart(key, ts.getType());
- appendVersionKeyPart(key, ts.getValue());
- }
- if (scanParams != null && scanParams.isPresent()) {
- TableScanParams sp = scanParams.get();
- key.append('p');
- appendVersionKeyPart(key, sp.getParamType());
- Map sortedParams = new TreeMap<>(sp.getMapParams());
- key.append('m').append(sortedParams.size()).append(':');
- for (Map.Entry entry : sortedParams.entrySet()) {
- // Length prefixes preserve entry boundaries even when user values contain Map.toString()
- // delimiters; sorting separately keeps semantically identical maps order-independent.
- appendVersionKeyPart(key, entry.getKey());
- appendVersionKeyPart(key, entry.getValue());
- }
- key.append('l').append(sp.getListParams().size()).append(':');
- for (String value : sp.getListParams()) {
- appendVersionKeyPart(key, value);
- }
- }
- return key.toString();
- }
-
- private static void appendVersionKeyPart(StringBuilder key, Object value) {
- String text = String.valueOf(value);
- key.append(text.length()).append(':').append(text);
+ return MvccReadSelector.of(tableSnapshot, scanParams).getKey();
}
/**
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
index 87c4d800486ba3..b73b1c098fa204 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
@@ -48,6 +48,7 @@
import org.apache.doris.datasource.ExternalTable;
import org.apache.doris.datasource.ExternalView;
import org.apache.doris.datasource.doris.RemoteDorisExternalTable;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable;
import org.apache.doris.datasource.systable.SysTableResolver;
@@ -938,11 +939,20 @@ private LogicalPlan getLogicalPlan(TableIf table, UnboundRelation unboundRelatio
sqlCacheContext.addUsedTable(table);
} else if (table instanceof ExternalTable && table instanceof MTMVRelatedTableIf
&& cascadesContext.getConnectContext().getSessionVariable().enableHiveSqlCache) {
- // Any external lakehouse plugin table that exposes a stable data-version token
- // (MTMVRelatedTableIf#getNewestUpdateVersionOrTime) is cacheable; addUsedTable
- // records the token and fails safe if it is unavailable. Gated by the (default
- // false) enable_hive_sql_cache switch so behavior is unchanged unless opted in.
- sqlCacheContext.addUsedTable(table);
+ // Record a connector's lightweight SQL-cache version when available, otherwise
+ // retain the legacy freshness path. The external cache opt-in applies to both.
+ // Incremental/options scans need their own cache identity contract; point-in-time
+ // references use the same selector and snapshot as ordinary analysis and scanning.
+ TableScanParams params = unboundRelation.getScanParams();
+ if (params != null && !params.isBranch() && !params.isTag()) {
+ sqlCacheContext.setHasUnsupportedTables(true);
+ } else {
+ MvccReadSelector selector = MvccReadSelector.of(unboundRelation.getTableSnapshot(),
+ Optional.ofNullable(params));
+ sqlCacheContext.addUsedTable(table, cascadesContext.getStatementContext()
+ .getSnapshot(table, selector.getTableSnapshot(), selector.getScanParams()),
+ selector);
+ }
} else {
sqlCacheContext.setHasUnsupportedTables(true);
}
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 5589026206a849..4ee20db8c1cc43 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
@@ -61,7 +61,8 @@ public class PruneFileScanPartition extends OneRewriteRuleFactory {
@Override
public Rule build() {
- return logicalFilter(logicalFileScan()).whenNot(p -> p.child().getSelectedPartitions().isPruned)
+ return logicalFilter(logicalFileScan()).whenNot(p -> p.child().getSelectedPartitions().isPruned
+ || p.child().getSelectedPartitions().isNativePlanning())
.thenApply(ctx -> {
LogicalFilter filter = ctx.root;
LogicalFileScan scan = filter.child();
@@ -134,12 +135,8 @@ private SelectedPartitions pruneExternalPartitions(ExternalTable externalTable,
for (String name : prunedPartitions) {
PartitionItem item = nameToPartitionItem.get(name);
- // 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.
+ // The index and selected map must describe the same pinned partition generation.
+ // Plugin MVCC views publish them together and share the very same PartitionItem objects.
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/main/java/org/apache/doris/nereids/rules/rewrite/QueryPartitionCollector.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/QueryPartitionCollector.java
index e0c833f95b14b1..e9d69f96163b7d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/QueryPartitionCollector.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/QueryPartitionCollector.java
@@ -61,8 +61,8 @@ public Void visitLogicalCatalogRelation(LogicalCatalogRelation catalogRelation,
// Collect table used partition mapping
Multimap, Pair>> tableUsedPartitionNameMap = statementContext
.getTableUsedPartitionNameMap();
- Set tablePartitions = new HashSet<>();
if (catalogRelation instanceof LogicalOlapScan) {
+ Set tablePartitions = new HashSet<>();
// Handle olap table
LogicalOlapScan logicalOlapScan = (LogicalOlapScan) catalogRelation;
for (Long partitionId : logicalOlapScan.getSelectedPartitionIds()) {
@@ -72,12 +72,18 @@ public Void visitLogicalCatalogRelation(LogicalCatalogRelation catalogRelation,
Pair.of(catalogRelation.getRelationId(), tablePartitions));
} else if (catalogRelation instanceof LogicalFileScan
&& catalogRelation.getTable() != null
- && ((ExternalTable) catalogRelation.getTable()).supportInternalPartitionPruned()) {
+ && (((LogicalFileScan) catalogRelation).getSelectedPartitions().isNativePlanning()
+ || ((ExternalTable) catalogRelation.getTable()).supportInternalPartitionPruned())) {
LogicalFileScan logicalFileScan = (LogicalFileScan) catalogRelation;
SelectedPartitions selectedPartitions = logicalFileScan.getSelectedPartitions();
- tablePartitions.addAll(selectedPartitions.selectedPartitions.keySet());
+ if (selectedPartitions.isNativePlanning()) {
+ tableUsedPartitionNameMap.put(table.getFullQualifiers(), PartitionCompensator.ALL_PARTITIONS);
+ return null;
+ }
+ // SelectedPartitions owns an immutable map. Keep its key view rather than copying every
+ // partition name; compensation builds its own mutable union when it is actually needed.
tableUsedPartitionNameMap.put(table.getFullQualifiers(),
- Pair.of(catalogRelation.getRelationId(), tablePartitions));
+ Pair.of(catalogRelation.getRelationId(), selectedPartitions.selectedPartitions.keySet()));
} else {
// not support get partition scene, we consider query all partitions from table
tableUsedPartitionNameMap.put(table.getFullQualifiers(), PartitionCompensator.ALL_PARTITIONS);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteAction.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteAction.java
index 89e0ea8654e17e..eff54ad15ebc50 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteAction.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteAction.java
@@ -128,7 +128,7 @@ public ResultSet execute(TableIf ignored) throws UserException {
ConnectorSession session = catalog.buildConnectorSession();
ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector);
ConnectorTableHandle tableHandle = metadata
- .getTableHandle(session, table.getRemoteDbName(), table.getRemoteName())
+ .getTableHandleForDdl(session, table.getRemoteDbName(), table.getRemoteName())
.orElseThrow(() -> new AnalysisException("Table not found: " + table.getRemoteDbName()
+ "." + table.getRemoteName() + " in catalog " + catalog.getName()));
@@ -156,6 +156,10 @@ public ResultSet execute(TableIf ignored) throws UserException {
// here (engine half, no iceberg types); the converter is fail-loud, so an unrepresentable WHERE throws
// rather than silently widening the rewrite scope.
if (mode == ProcedureExecutionMode.DISTRIBUTED) {
+ // Distributed rewriting plans file scans; acquire the scan-capable read handle only for this mode.
+ tableHandle = metadata.getTableHandle(session, table.getRemoteDbName(), table.getRemoteName())
+ .orElseThrow(() -> new AnalysisException("Table not found: " + table.getRemoteDbName()
+ + "." + table.getRemoteName() + " in catalog " + catalog.getName()));
ConnectorPredicate loweredWhere = whereCondition.isPresent()
? UnboundExpressionToConnectorPredicateConverter.convert(whereCondition.get(), table)
: null;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java
index 09b5e549b5a473..2dc541f5a45d78 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalFileScan.java
@@ -305,6 +305,10 @@ public static class SelectedPartitions {
// Or used to indicate that the partition pruning is not processed.
public static SelectedPartitions NOT_PRUNED = new SelectedPartitions(0, ImmutableMap.of(), false, false,
Optional.empty());
+ /** Connector-owned file planning; an unknown universe, never an empty pruned selection. */
+ public static final SelectedPartitions NATIVE = new SelectedPartitions(
+ -1, ImmutableMap.of(), false, false, Optional.empty(), true);
+
/**
* total partition number
*/
@@ -334,6 +338,8 @@ public static class SelectedPartitions {
/**
* Constructor for SelectedPartitions.
*/
+ private final boolean nativePlanning;
+
public SelectedPartitions(long totalPartitionNum, Map selectedPartitions,
boolean isPruned) {
this(totalPartitionNum, selectedPartitions, isPruned, false, Optional.empty());
@@ -353,6 +359,13 @@ public SelectedPartitions(long totalPartitionNum, Map sel
public SelectedPartitions(long totalPartitionNum, Map selectedPartitions,
boolean isPruned, boolean hasPartitionPredicate,
Optional> sortedPartitionRanges) {
+ this(totalPartitionNum, selectedPartitions, isPruned, hasPartitionPredicate, sortedPartitionRanges, false);
+ }
+
+ private SelectedPartitions(long totalPartitionNum, Map selectedPartitions,
+ boolean isPruned, boolean hasPartitionPredicate,
+ Optional> sortedPartitionRanges, boolean nativePlanning) {
+ this.nativePlanning = nativePlanning;
this.totalPartitionNum = totalPartitionNum;
this.selectedPartitions = ImmutableMap.copyOf(Objects.requireNonNull(selectedPartitions,
"selectedPartitions is null"));
@@ -361,6 +374,10 @@ public SelectedPartitions(long totalPartitionNum, Map sel
this.sortedPartitionRanges = sortedPartitionRanges;
}
+ public boolean isNativePlanning() {
+ return nativePlanning;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -370,7 +387,7 @@ public boolean equals(Object o) {
return false;
}
SelectedPartitions that = (SelectedPartitions) o;
- return isPruned == that.isPruned
+ return nativePlanning == that.nativePlanning && isPruned == that.isPruned
&& hasPartitionPredicate == that.hasPartitionPredicate
&& Objects.equals(
selectedPartitions.keySet(), that.selectedPartitions.keySet())
@@ -380,7 +397,8 @@ public boolean equals(Object o) {
@Override
public int hashCode() {
- return Objects.hash(selectedPartitions, isPruned, hasPartitionPredicate, sortedPartitionRanges.isPresent());
+ return Objects.hash(selectedPartitions, isPruned, hasPartitionPredicate,
+ sortedPartitionRanges.isPresent(), nativePlanning);
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index 6c65b9ddaf40c0..f16f9639e13573 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -589,6 +589,8 @@ public String toString() {
public static final String ICEBERG_WRITE_TARGET_FILE_SIZE_BYTES = "iceberg_write_target_file_size_bytes";
public static final String ENABLE_ICEBERG_MERGE_PARTITIONING = "enable_iceberg_merge_partitioning";
public static final String ENABLE_PAIMON_WRITE_CHAR_PADDING = "enable_paimon_write_char_padding";
+ public static final String ENABLE_ICEBERG_INTERNAL_PARTITION_PRUNING =
+ "enable_iceberg_internal_partition_pruning";
public static final String NUM_PARTITIONS_IN_BATCH_MODE = "num_partitions_in_batch_mode";
@@ -3734,6 +3736,11 @@ public void checkAnnIndexCandidateRowsPercentThreshold(String value) {
options = {ICEBERG_PARTITION_OVERWRITE_MODE_STATIC, ICEBERG_PARTITION_OVERWRITE_MODE_DYNAMIC})
public String icebergPartitionOverwriteMode = ICEBERG_PARTITION_OVERWRITE_MODE_STATIC;
+ @VarAttrDef.VarAttr(name = ENABLE_ICEBERG_INTERNAL_PARTITION_PRUNING,
+ description = "Enable Doris FE partition pruning for Iceberg scans. When disabled, ordinary scans "
+ + "defer the Doris partition view and rely on Iceberg native file pruning. Materialized view "
+ + "planning and partition metadata rules still require the full partition view.")
+ public boolean enableIcebergInternalPartitionPruning = false;
// If this fe is in fuzzy mode, then will use initFuzzyModeVariables to generate some variables,
// not the default value set in the code.
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java
index 1b5827ad878f6a..d73315facc9cee 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java
@@ -33,13 +33,17 @@
import org.apache.doris.common.Status;
import org.apache.doris.common.UserException;
import org.apache.doris.common.util.DebugUtil;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.datasource.CatalogIf;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
+import org.apache.doris.datasource.scan.FileQueryScanNode;
import org.apache.doris.metric.MetricRepo;
import org.apache.doris.mtmv.MTMVRelatedTableIf;
import org.apache.doris.nereids.NereidsPlanner;
import org.apache.doris.nereids.SqlCacheContext;
import org.apache.doris.nereids.SqlCacheContext.FullTableName;
import org.apache.doris.nereids.SqlCacheContext.ScanTable;
+import org.apache.doris.nereids.SqlCacheContext.TableReference;
import org.apache.doris.nereids.glue.LogicalPlanAdapter;
import org.apache.doris.planner.OlapScanNode;
import org.apache.doris.planner.Planner;
@@ -234,6 +238,10 @@ private CacheMode innerCheckCacheModeForNereids(long now) {
return CacheMode.NoNeed;
}
+ if (Optional.ofNullable(context.getStatementContext()).flatMap(statement -> statement.getSqlCacheContext())
+ .map(cacheContext -> !cacheContext.supportSqlCache()).orElse(false)) {
+ return CacheMode.NoNeed;
+ }
//Check the last version time of the table
List tblTimeList = buildCacheTableList();
if (CollectionUtils.isEmpty(tblTimeList)) {
@@ -514,6 +522,26 @@ private CacheTable buildCacheTableForExternalScanNode(ScanNode node) {
CacheTable cacheTable = new CacheTable();
TableIf tableIf = node.getTupleDesc().getTable();
cacheTable.table = tableIf;
+ DatabaseIf database = tableIf.getDatabase();
+ CatalogIf catalog = database.getCatalog();
+ FullTableName name = new FullTableName(catalog.getName(), database.getFullName(), tableIf.getName());
+ MvccReadSelector selector = node instanceof FileQueryScanNode
+ ? MvccReadSelector.of(Optional.ofNullable(((FileQueryScanNode) node).getQueryTableSnapshot()),
+ Optional.ofNullable(((FileQueryScanNode) node).getScanParams()))
+ : MvccReadSelector.LATEST;
+ Optional version = Optional.ofNullable(context.getStatementContext())
+ .flatMap(statement -> statement.getSqlCacheContext())
+ .map(cacheContext -> cacheContext.getUsedTables().get(new TableReference(name, selector)))
+ .map(tableVersion -> tableVersion.sqlCacheVersion);
+ if (version.isPresent()) {
+ // The full identity is already in the SQL MD5 key. PCache needs only a stable timestamp and
+ // one logical whole-table unit; neither physical partition counts nor another latest probe.
+ cacheTable.partitionNum = 1;
+ cacheTable.latestPartitionTime = version.get().getUpdateTimeMillis();
+ cacheTable.latestPartitionUpdateMillis = version.get().getUpdateTimeMillis();
+ scanTables.add(Pair.of(new ScanTable(name), tableIf));
+ return cacheTable;
+ }
cacheTable.partitionNum = node.getSelectedPartitionNum();
// Connector-agnostic data-version token (hive: max transient_lastDdlTime; iceberg/paimon: monotonic
// snapshot version). Gated to MTMVRelatedTableIf tables by isExternalCacheableScanNode above. This is
@@ -524,10 +552,7 @@ private CacheTable buildCacheTableForExternalScanNode(ScanNode node) {
// millis here; hive/paimon already return millis). Kept separate from the token above so the gate never
// subtracts a non-wall-clock value.
cacheTable.latestPartitionUpdateMillis = ((MTMVRelatedTableIf) tableIf).getNewestUpdateTimeMillisForCache();
- DatabaseIf database = tableIf.getDatabase();
- CatalogIf catalog = database.getCatalog();
- ScanTable scanTable = new ScanTable(new FullTableName(
- catalog.getName(), database.getFullName(), tableIf.getName()));
+ ScanTable scanTable = new ScanTable(name);
scanTables.add(Pair.of(scanTable, tableIf));
return cacheTable;
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersionProvider.java b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersionProvider.java
new file mode 100644
index 00000000000000..0591ab239b719c
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersionProvider.java
@@ -0,0 +1,32 @@
+// 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.qe.cache;
+
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
+
+import java.util.Optional;
+
+/**
+ * Supplies a whole-table SQL result-cache version independently of MV/dictionary freshness semantics.
+ * A supplied snapshot is the query's read generation; empty probes the current generation for cache lookup.
+ * Empty results preserve the caller's legacy freshness path. Failures disable caching for the query.
+ */
+public interface SqlCacheVersionProvider {
+ Optional getSqlCacheVersion(Optional snapshot);
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/alter/ChangelogConfigPublicationTest.java b/fe/fe-core/src/test/java/org/apache/doris/alter/ChangelogConfigPublicationTest.java
index 5c364b5fcf0ec2..7d4a0c9d3139e0 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/alter/ChangelogConfigPublicationTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/alter/ChangelogConfigPublicationTest.java
@@ -39,6 +39,7 @@
import org.apache.doris.common.cache.NereidsSqlCacheManager;
import org.apache.doris.common.util.PropertyAnalyzer;
import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
import org.apache.doris.nereids.SqlCacheContext;
import org.apache.doris.nereids.trees.plans.commands.info.ModifyTablePropertiesOp;
import org.apache.doris.persist.EditLog;
@@ -199,7 +200,8 @@ public void failedPublicationCanBeReplayedAndRetriedByANewMaster() throws Except
SqlCacheContext cachedRead = Mockito.mock(SqlCacheContext.class);
SqlCacheContext.TableVersion version = new SqlCacheContext.TableVersion(table.getId(), 1, Table.TableType.OLAP);
Mockito.when(cachedRead.getUsedTables()).thenReturn(ImmutableMap.of(
- new SqlCacheContext.FullTableName("internal", "db", "tbl"), version));
+ new SqlCacheContext.TableReference(new SqlCacheContext.FullTableName("internal", "db", "tbl"),
+ MvccReadSelector.LATEST), version));
cache.getSqlCaches().put("cached-incr-read", cachedRead);
Mockito.when(replayEnv.getSqlCacheManager()).thenReturn(cache);
InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
diff --git a/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java b/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java
index cb66b2a52f33ce..cc083c62c40eb3 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java
@@ -30,6 +30,33 @@
import java.util.concurrent.ConcurrentHashMap;
public class SqlBlockRuleMgrTest {
+ @Test
+ public void testPartitionMetadataIsRequiredOnlyByEnabledPartitionRules() {
+ SqlBlockRuleMgr mgr = new SqlBlockRuleMgr();
+ SqlBlockRule rule = new SqlBlockRule("r", "NULL", "NULL", 0L, 0L, 10L,
+ false, false, true);
+ mgr.getNameToSqlBlockRuleMap().put("r", rule);
+ Assertions.assertFalse(mgr.hasPartitionMetadataRules());
+ rule.setPartitionNum(2L);
+ Assertions.assertTrue(mgr.hasPartitionMetadataRules());
+ rule.setEnable(false);
+ Assertions.assertFalse(mgr.hasPartitionMetadataRules());
+ rule.setPartitionNum(0L);
+ rule.setRequirePartitionFilter(true);
+ rule.setEnable(true);
+ Assertions.assertTrue(mgr.hasPartitionMetadataRules());
+ }
+
+ @Test
+ public void testNewPartitionRuleCannotTreatAnUnknownUniverseAsZero() {
+ SqlBlockRuleMgr mgr = new SqlBlockRuleMgr();
+ SqlBlockRule rule = new SqlBlockRule("r", "NULL", "NULL", 1L, 0L, 0L,
+ false, true, true);
+ AnalysisException error = Assertions.assertThrows(AnalysisException.class,
+ () -> mgr.checkLimitations(rule, -1L, 1L, 10L, true, false));
+ Assertions.assertTrue(error.getMessage().contains("retry the query"));
+ }
+
@BeforeAll
public static void setUp() {
MetricRepo.init();
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
index 467b96dd2fb011..23180262aa5533 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSortedPartitionsCacheManagerExternalTest.java
@@ -296,22 +296,10 @@ public void testPluginDrivenMvccExternalTableRealOriginPartitionsAndVersion() th
// ──────────────────── 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.
+ // External MVCC tables return the index owned by their pinned partition view. These cases
+ // verify that changes in partition content remain visible even at a constant snapshot id.
- /**
- * 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.
- */
+ /** Supplies the legacy manager for tests that also cover generic ExternalTable delegation. */
private static MockedStatic mockEnvWithRangesCacheManager(NereidsSortedPartitionsCacheManager rangesCacheMgr) {
Env env = Mockito.mock(Env.class);
Mockito.when(env.getSortedPartitionsCacheManager()).thenReturn(rangesCacheMgr);
@@ -381,8 +369,7 @@ public void testGetSortedPartitionRangesPresentForSnapshotLessNonEmptyPartitions
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");
+ "snapshot-less tables also publish an index with their non-empty partition view");
} finally {
ConnectContext.remove();
}
@@ -402,8 +389,7 @@ public void testGetSortedPartitionRangesRebuildsWhenSnapshotLessNameSetChanges()
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.
+ // The new pin owns its new index even though snapshotId is still -1 on both pins.
Map partsAtQuery2 = Maps.newHashMap();
partsAtQuery2.put("id=1", listItem(1));
partsAtQuery2.put("id=2", listItem(2));
@@ -435,16 +421,17 @@ public void testGetSortedPartitionRangesRebuildsWhenRealSnapshotNameSetChanges()
Map partsAtQuery1 = Maps.newHashMap();
partsAtQuery1.put("id=1", listItem(1));
partsAtQuery1.put("id=2", listItem(2));
- LogicalFileScan scan1 = pinLatestAndBuildScan(table, realSnapshotPin(partsAtQuery1));
+ PluginDrivenMvccSnapshot firstPin = realSnapshotPin(partsAtQuery1);
+ LogicalFileScan scan1 = pinLatestAndBuildScan(table, firstPin);
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));
+ // A reused partition view owns the same index; no second table-wide sorted-cache lookup.
+ LogicalFileScan scanHit = pinLatestAndBuildScan(table, firstPin);
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");
+ "the same pinned view must return the same index");
// 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.
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheManagerPluginTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheManagerPluginTableTest.java
index 44cf75f2f11d75..baa61b6c91614b 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheManagerPluginTableTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheManagerPluginTableTest.java
@@ -17,15 +17,20 @@
package org.apache.doris.common.cache;
+import org.apache.doris.analysis.TableSnapshot;
import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.TableIf.TableType;
import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.CatalogMgr;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
import org.apache.doris.datasource.mvcc.PluginDrivenMvccExternalTable;
import org.apache.doris.nereids.SqlCacheContext;
+import org.apache.doris.nereids.StatementContext;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -49,6 +54,81 @@ public class NereidsSqlCacheManagerPluginTableTest {
private static final String TBL = "t";
private static final long TABLE_ID = 42L;
+ @Test
+ public void nativeVersionInvalidatesByIdentityWithoutOrderingOrPartitionFreshness() {
+ PluginDrivenMvccExternalTable stored = mockTable(123L);
+ Mockito.when(stored.getSqlCacheVersion(Optional.empty()))
+ .thenReturn(Optional.of(new ConnectorSqlCacheVersion("uuid:9:schema1:metadata1", 1000L)));
+ SqlCacheContext context = new SqlCacheContext(UserIdentity.ROOT);
+ context.addUsedTable(stored);
+ PluginDrivenMvccExternalTable live = mockTable(123L);
+ Env env = mockEnvResolvingTo(live);
+ for (String token : new String[] {"uuid:9:schema1:metadata1", "uuid:2:schema1:metadata2",
+ "uuid:9:schema2:metadata3", "new-uuid:9:schema1:metadata1"}) {
+ Mockito.when(live.getSqlCacheVersion(Optional.empty()))
+ .thenReturn(Optional.of(new ConnectorSqlCacheVersion(token, 1000L)));
+ Object verdict = Deencapsulation.invoke(new NereidsSqlCacheManager(), "tablesOrDataChanged",
+ env, context, new StatementContext());
+ Assertions.assertEquals(!token.equals("uuid:9:schema1:metadata1"), isChangedField(verdict, "changed"));
+ }
+ Mockito.when(live.getSqlCacheVersion(Optional.empty())).thenReturn(Optional.empty());
+ Object verdict = Deencapsulation.invoke(new NereidsSqlCacheManager(), "tablesOrDataChanged",
+ env, context, new StatementContext());
+ Assertions.assertTrue(isChangedField(verdict, "invalidCache"));
+ Mockito.verify(stored, Mockito.never()).getNewestUpdateVersionOrTime();
+ Mockito.verify(live, Mockito.never()).getNewestUpdateVersionOrTime();
+ }
+
+ @Test
+ public void revalidationResolvesEachOriginalReferenceAndInvalidatesOnlyWhenItsVersionChanges() {
+ PluginDrivenMvccExternalTable table = mockTable(999L);
+ MvccSnapshot mainPin = Mockito.mock(MvccSnapshot.class);
+ MvccSnapshot devPin = Mockito.mock(MvccSnapshot.class);
+ ConnectorSqlCacheVersion main = new ConnectorSqlCacheVersion("uuid:100:schema3:metadata", 1000L);
+ ConnectorSqlCacheVersion dev = new ConnectorSqlCacheVersion("uuid:80:schema3:metadata", 800L);
+ Mockito.when(table.getSqlCacheVersion(Optional.of(mainPin))).thenReturn(Optional.of(main));
+ Mockito.when(table.getSqlCacheVersion(Optional.of(devPin))).thenReturn(Optional.of(dev));
+ Mockito.when(table.getSqlCacheVersion(Optional.empty())).thenReturn(Optional.of(main));
+ MvccReadSelector selector = MvccReadSelector.of(Optional.of(TableSnapshot.versionOf("dev")), Optional.empty());
+ SqlCacheContext context = new SqlCacheContext(UserIdentity.ROOT);
+ context.addUsedTable(table, Optional.of(mainPin));
+ context.addUsedTable(table, Optional.of(devPin), selector);
+ StatementContext statement = Mockito.mock(StatementContext.class);
+ Mockito.when(statement.getSnapshot(Mockito.eq(table), Mockito.any(), Mockito.any()))
+ .thenAnswer(call -> {
+ Optional requested = call.getArgument(1);
+ Assertions.assertEquals("dev", requested.orElseThrow().getValue());
+ return Optional.of(devPin);
+ });
+ Env env = mockEnvResolvingTo(table);
+ Object same = Deencapsulation.invoke(new NereidsSqlCacheManager(), "tablesOrDataChanged", env, context, statement);
+ Assertions.assertFalse(isChangedField(same, "changed"));
+ Mockito.verify(statement).loadSnapshots(Mockito.eq(table), Mockito.any(), Mockito.any());
+ Mockito.when(table.getSqlCacheVersion(Optional.of(devPin)))
+ .thenReturn(Optional.of(new ConnectorSqlCacheVersion("uuid:90:schema3:metadata", 800L)));
+ Object moved = Deencapsulation.invoke(new NereidsSqlCacheManager(), "tablesOrDataChanged", env, context, statement);
+ Assertions.assertTrue(isChangedField(moved, "invalidCache"));
+ Mockito.verify(table, Mockito.never()).getNewestUpdateVersionOrTime();
+ }
+
+ @Test
+ public void expiredSnapshotCannotPassVersionValidation() {
+ PluginDrivenMvccExternalTable table = mockTable(999L);
+ MvccSnapshot pin = Mockito.mock(MvccSnapshot.class);
+ Mockito.when(table.getSqlCacheVersion(Optional.of(pin)))
+ .thenReturn(Optional.of(new ConnectorSqlCacheVersion("uuid:80:schema3:metadata", 800L)));
+ SqlCacheContext context = new SqlCacheContext(UserIdentity.ROOT);
+ context.addUsedTable(table, Optional.of(pin),
+ MvccReadSelector.of(Optional.of(TableSnapshot.versionOf("80")), Optional.empty()));
+ StatementContext statement = Mockito.mock(StatementContext.class);
+ Mockito.doThrow(new IllegalStateException("can't find snapshot by id: 80"))
+ .when(statement).loadSnapshots(Mockito.eq(table), Mockito.any(), Mockito.any());
+ Env env = mockEnvResolvingTo(table);
+ Assertions.assertThrows(IllegalStateException.class,
+ () -> Deencapsulation.invoke(new NereidsSqlCacheManager(), "tablesOrDataChanged", env, context, statement));
+ Mockito.verify(table, Mockito.never()).getSqlCacheVersion(Optional.empty());
+ }
+
private PluginDrivenMvccExternalTable mockTable(long liveToken) {
PluginDrivenMvccExternalTable table = Mockito.mock(PluginDrivenMvccExternalTable.class);
DatabaseIf db = Mockito.mock(DatabaseIf.class);
@@ -93,7 +173,8 @@ public void testNotChangedWhenTokenUnchanged() {
Env env = mockEnvResolvingTo(mockTable(token)); // live token unchanged
Object verdict = Deencapsulation.invoke(new NereidsSqlCacheManager(),
- "tablesOrDataChanged", env, context);
+ "tablesOrDataChanged",
+ env, context, new StatementContext());
Assertions.assertFalse(isChangedField(verdict, "changed"),
"an unchanged token must keep the cache (NOT_CHANGED)");
@@ -108,7 +189,8 @@ public void testInvalidateWhenTokenChanged() {
Env env = mockEnvResolvingTo(mockTable(storedToken + 1000L)); // data mutated: newer token
Object verdict = Deencapsulation.invoke(new NereidsSqlCacheManager(),
- "tablesOrDataChanged", env, context);
+ "tablesOrDataChanged",
+ env, context, new StatementContext());
Assertions.assertTrue(isChangedField(verdict, "changed"),
"a changed token must invalidate the cache");
diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java
index d59e5edfc2115d..0df3af0bacf59a 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java
@@ -24,6 +24,7 @@
import org.apache.doris.connector.spi.handle.ConnectorTransaction;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -141,6 +142,24 @@ public void testBeExecVersionFallsBackToTheEngineConfigOutsideAPlannedStatement(
}
}
+ @Test
+ public void testIcebergInternalPartitionPruningFollowsTheSessionVariable() {
+ ConnectContext ctx = new ConnectContext();
+ ctx.setThreadLocalInfo();
+ try {
+ ConnectorSession original = ConnectorSessionBuilder.from(ctx).build();
+ String key = SessionVariable.ENABLE_ICEBERG_INTERNAL_PARTITION_PRUNING;
+ Assertions.assertEquals("false", original.getSessionProperties().get(key));
+ ctx.getSessionVariable().enableIcebergInternalPartitionPruning = true;
+ Assertions.assertEquals("true", ConnectorSessionBuilder.from(ctx).build()
+ .getSessionProperties().get(key));
+ Assertions.assertEquals("false", original.getSessionProperties().get(key),
+ "changing the session must not change a captured connector session");
+ } finally {
+ ConnectContext.remove();
+ }
+ }
+
@Test
public void testSessionPropertyOverridesCatalogProperty() {
Map catalogProps = new HashMap<>();
diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java
index cb7777dd108db6..ada9a96ef20a53 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java
@@ -55,6 +55,22 @@
*/
public class ConnectorStatementScopeTest {
+ @Test
+ public void lookupDoesNotLoadAndRespectsPlanningAndStatementCleanup() {
+ ConnectorStatementScopeImpl scope = new ConnectorStatementScopeImpl();
+ Assertions.assertNull(scope.getIfPresent("missing"));
+ Object table = scope.computeIfAbsent("table", Object::new);
+ Object planning = scope.computeIfAbsentForPlanning("planning", Object::new);
+ Assertions.assertSame(table, scope.getIfPresent("table"));
+ Assertions.assertSame(planning, scope.getIfPresent("planning"));
+ scope.endPlanning();
+ Assertions.assertNull(scope.getIfPresent("planning"));
+ Assertions.assertSame(table, scope.getIfPresent("table"));
+ scope.closeAll();
+ Assertions.assertNull(scope.getIfPresent("table"));
+ Assertions.assertNull(ConnectorStatementScope.NONE.getIfPresent("table"));
+ }
+
@Test
public void noneNeverMemoizes() {
// NONE = the off-context default (offline / no live statement): the loader runs every call, so a
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCacheTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCacheTest.java
index 7524288f2cb593..174872b738d057 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCacheTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/metacache/ExternalCatalogMetaCacheTest.java
@@ -45,9 +45,48 @@
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
public class ExternalCatalogMetaCacheTest {
+ @Test
+ public void contextualEntriesPreserveTheirSizeEstimatorAcrossCatalogRuntimes() {
+ ExecutorService refreshExecutor = Executors.newSingleThreadExecutor();
+ AtomicInteger estimates = new AtomicInteger();
+ ExternalCatalogMetaCache cache = new ExternalCatalogMetaCache("weighted_test", refreshExecutor) {
+ {
+ registerEntry(MetaCacheEntryDef.contextualOnly("large", String.class, LargeValue.class,
+ CacheSpec.ofWeight(true, CacheSpec.CACHE_NO_TTL, 10, 2_000_000))
+ .withSizeEstimator((key, value) -> {
+ estimates.incrementAndGet();
+ return MetaCacheSizeEstimate.complete(800_000);
+ }));
+ }
+ };
+ try {
+ LargeValue value = new LargeValue();
+ Assertions.assertEquals(20_000, value.values.size());
+ for (long catalogId : new long[] {1L, 2L}) {
+ cache.initCatalog(catalogId, Map.of("meta.cache.weighted_test.large.max-weight", "2000000"));
+ MetaCache entry = cache.entry(catalogId, "large", String.class, LargeValue.class);
+ Assertions.assertSame(value, entry.get("key", key -> value));
+ Assertions.assertSame(value, entry.get("key", key -> {
+ throw new AssertionError("contextual entry must use its own complete size estimate");
+ }));
+ cache.invalidateCatalog(catalogId);
+ }
+ Assertions.assertEquals(2, estimates.get());
+ } finally {
+ refreshExecutor.shutdownNow();
+ }
+ }
+
+ private static final class LargeValue {
+ private final List values = IntStream.range(0, 20_000)
+ .mapToObj(Integer::toString).collect(Collectors.toList());
+ }
+
@Test
public void testEntryRequiresExplicitInit() {
ExecutorService refreshExecutor = Executors.newSingleThreadExecutor();
@@ -191,7 +230,7 @@ public void testWideSchemaIsCachedUnderAWeightLimit() {
@Test
public void testSchemaEstimateIsCompleteLinearAndCountsReusedDisplayColumnsOnce() {
MetaCacheSizeEstimator estimator =
- ExternalCatalogMetaCache.sizeEstimator(SchemaCacheValue.class);
+ MetaCacheEntryDef.defaultSizeEstimator(SchemaCacheValue.class);
SchemaCacheKey key = new SchemaCacheKey(NameMapping.createForTest(3L, "db1", "wide"));
MetaCacheSizeEstimate half = estimator.estimate(key, new SchemaCacheValue(wideColumns(2_000, 500)));
List columns = wideColumns(4_000, 1_000);
@@ -210,7 +249,7 @@ public void testSchemaEstimateIsCompleteLinearAndCountsReusedDisplayColumnsOnce(
// A plugin table's display columns reuse its column objects; they must not be weighed twice. MUTATION:
// estimating every element of every column list -> about twice the weight -> red.
MetaCacheSizeEstimator pluginEstimator =
- ExternalCatalogMetaCache.sizeEstimator(PluginDrivenSchemaCacheValue.class);
+ MetaCacheEntryDef.defaultSizeEstimator(PluginDrivenSchemaCacheValue.class);
long sameList = pluginEstimator.estimate(key, new PluginDrivenSchemaCacheValue(columns,
Collections.emptyList(), Collections.emptyList(), null, null, null, columns)).getBytes();
long copiedList = pluginEstimator.estimate(key, new PluginDrivenSchemaCacheValue(columns,
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/MvccReadSelectorTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/MvccReadSelectorTest.java
new file mode 100644
index 00000000000000..efd64f4b7c4acb
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/MvccReadSelectorTest.java
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.mvcc;
+
+import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.analysis.TableSnapshot;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+public class MvccReadSelectorTest {
+ @Test
+ public void historicalSelectorIsUnaffectedByParserOrGetterMutations() {
+ TableSnapshot original = TableSnapshot.versionOf("100");
+ MvccReadSelector selector = MvccReadSelector.of(Optional.of(original), Optional.empty());
+ original.setValue("200");
+ selector.getTableSnapshot().orElseThrow().setValue("300");
+ Assertions.assertEquals("100", selector.getTableSnapshot().orElseThrow().getValue());
+ Assertions.assertEquals(MvccReadSelector.of(Optional.of(TableSnapshot.versionOf("100")), Optional.empty()),
+ selector);
+ Assertions.assertNotEquals(MvccReadSelector.of(Optional.of(TableSnapshot.timeOf("100")), Optional.empty()),
+ selector);
+ }
+
+ @Test
+ public void dynamicParametersAreResolvedAgainForEachUse() {
+ TableScanParams original = new TableScanParams("options", Map.of("branch", "dev"), Collections.emptyList());
+ original.reuseResolvedMapParams(Map.of("snapshot-id", "100"));
+ MvccReadSelector selector = MvccReadSelector.of(Optional.empty(), Optional.of(original));
+ TableScanParams first = selector.getScanParams().orElseThrow();
+ Assertions.assertTrue(first.getResolvedMapParams().isEmpty());
+ first.reuseResolvedMapParams(Map.of("snapshot-id", "200"));
+ Assertions.assertTrue(selector.getScanParams().orElseThrow().getResolvedMapParams().isEmpty());
+ Assertions.assertEquals(Map.of("branch", "dev"), selector.getScanParams().orElseThrow().getMapParams());
+ }
+
+ @Test
+ public void canonicalKeysPreserveMapEntryBoundariesAndIgnoreMapOrder() {
+ Map ordered = new LinkedHashMap<>();
+ ordered.put("x", "1");
+ ordered.put("y", "2");
+ Map reversed = new LinkedHashMap<>();
+ reversed.put("y", "2");
+ reversed.put("x", "1");
+ MvccReadSelector a = MvccReadSelector.of(Optional.empty(), Optional.of(
+ new TableScanParams("options", ordered, Collections.emptyList())));
+ MvccReadSelector b = MvccReadSelector.of(Optional.empty(), Optional.of(
+ new TableScanParams("options", reversed, Collections.emptyList())));
+ MvccReadSelector collision = MvccReadSelector.of(Optional.empty(), Optional.of(
+ new TableScanParams("options", Map.of("x", "1, y=2"), Collections.emptyList())));
+ Assertions.assertEquals(a, b);
+ Assertions.assertEquals(a.hashCode(), b.hashCode());
+ Assertions.assertNotEquals(a, collision);
+ Assertions.assertSame(MvccReadSelector.LATEST, MvccReadSelector.of(Optional.empty(), Optional.empty()));
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
index f232aa9f0cca4d..062af920cf97ff 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTableTest.java
@@ -19,8 +19,12 @@
import org.apache.doris.analysis.TableScanParams;
import org.apache.doris.analysis.TableSnapshot;
+import org.apache.doris.blockrule.SqlBlockRule;
+import org.apache.doris.blockrule.SqlBlockRuleMgr;
import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.ListPartitionItem;
+import org.apache.doris.catalog.MTMV;
import org.apache.doris.catalog.PartitionItem;
import org.apache.doris.catalog.PartitionKey;
import org.apache.doris.catalog.PartitionType;
@@ -40,11 +44,13 @@
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartition;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccPartitionView;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
+import org.apache.doris.connector.spi.mvcc.ConnectorSqlCacheVersion;
import org.apache.doris.connector.spi.mvcc.ConnectorTableFreshness;
import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
import org.apache.doris.connector.spi.scan.ConnectorPartitionValues;
import org.apache.doris.datasource.ExternalCatalog;
import org.apache.doris.datasource.ExternalDatabase;
+import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.datasource.SchemaCacheValue;
import org.apache.doris.datasource.SessionContext;
import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog;
@@ -85,6 +91,11 @@
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.function.Function;
import java.util.stream.Collectors;
/**
@@ -159,6 +170,167 @@ public void sourceColumnPruningUsesTheRetainedPartitionView() {
Assertions.assertTrue(range.table.supportInternalPartitionPruned(rangePin));
}
+ @Test
+ public void sqlCacheVersionUsesTheSuppliedScalarPinWithoutMaterializingPartitions() {
+ Fixture f = Fixture.partitioned();
+ ConnectorMvccSnapshot scalar = ConnectorMvccSnapshot.builder().snapshotId(7L).build();
+ PluginDrivenMvccSnapshot pin = new PluginDrivenMvccSnapshot(scalar,
+ Collections.emptyMap(), Collections.emptyMap());
+ ConnectorSqlCacheVersion version = new ConnectorSqlCacheVersion("uuid:7:schema1:metadata1", 1000L);
+ Mockito.when(f.metadata.getSqlCacheVersion(f.session, f.pinnedHandle)).thenReturn(Optional.of(version));
+ Assertions.assertSame(version, f.table.getSqlCacheVersion(Optional.of(pin)).orElseThrow());
+ Mockito.verify(f.metadata).applySnapshot(f.session, f.handle, scalar);
+ Mockito.verify(f.metadata, Mockito.never()).beginQuerySnapshot(Mockito.any(), Mockito.any());
+ Mockito.verify(f.metadata, Mockito.never()).getMvccPartitionView(Mockito.any(), Mockito.any());
+ Mockito.verify(f.metadata, Mockito.never()).listPartitions(Mockito.any(), Mockito.any(), Mockito.any());
+ }
+
+ @Test
+ public void sqlCacheLookupResolvesLatestScalarVersionWithoutPartitionView() {
+ Fixture f = Fixture.partitioned();
+ ConnectorSqlCacheVersion version = new ConnectorSqlCacheVersion("uuid:latest:schema1:metadata1", 1000L);
+ Mockito.when(f.metadata.getSqlCacheVersion(f.session, f.pinnedHandle)).thenReturn(Optional.of(version));
+ Assertions.assertSame(version, f.table.getSqlCacheVersion(Optional.empty()).orElseThrow());
+ Mockito.verify(f.metadata).beginQuerySnapshot(f.session, f.handle);
+ Mockito.verify(f.metadata, Mockito.never()).getMvccPartitionView(Mockito.any(), Mockito.any());
+ Mockito.verify(f.metadata, Mockito.never()).listPartitions(Mockito.any(), Mockito.any(), Mockito.any());
+ }
+
+ private static ConnectContext nativeContext(Fixture f, SqlBlockRuleMgr rules) {
+ ConnectContext ctx = new ConnectContext();
+ Env env = Mockito.mock(Env.class);
+ Mockito.when(env.getSqlBlockRuleMgr()).thenReturn(rules);
+ InternalCatalog internal = Mockito.mock(InternalCatalog.class);
+ Mockito.when(internal.getName()).thenReturn(InternalCatalog.INTERNAL_CATALOG_NAME);
+ Mockito.when(env.getInternalCatalog()).thenReturn(internal);
+ ctx.setEnv(env);
+ ctx.setStatementContext(new StatementContext(ctx, null));
+ ctx.setThreadLocalInfo();
+ Mockito.when(f.metadata.supportsNativePartitionPlanning(f.session, f.pinnedHandle)).thenReturn(true);
+ return ctx;
+ }
+
+ @Test
+ public void ordinaryNativePlanningDoesNotEnumeratePartitions() {
+ Fixture f = Fixture.partitioned();
+ nativeContext(f, new SqlBlockRuleMgr());
+ PluginDrivenMvccSnapshot pin = (PluginDrivenMvccSnapshot)
+ f.table.loadSnapshot(Optional.empty(), Optional.empty());
+ Assertions.assertTrue(pin.hasDeferredPartitions());
+ Assertions.assertEquals(PINNED_SNAPSHOT_ID, pin.getConnectorSnapshot().getSnapshotId());
+ Assertions.assertSame(f.latestCacheValue, pin.getPinnedSchema());
+ Assertions.assertEquals(1, f.table.getPartitionColumns(Optional.of(pin)).size());
+ Assertions.assertSame(LogicalFileScan.SelectedPartitions.NATIVE,
+ f.table.initSelectedPartitions(Optional.of(pin)));
+ Mockito.verify(f.metadata, Mockito.never()).getMvccPartitionView(Mockito.any(), Mockito.any());
+ Mockito.verify(f.metadata, Mockito.never()).listPartitions(Mockito.any(), Mockito.any(), Mockito.any());
+ }
+
+ @Test
+ public void nativeSelectionKeepsTheFilterAndSkipsInternalPruning() {
+ Fixture f = Fixture.partitioned();
+ ConnectContext ctx = nativeContext(f, new SqlBlockRuleMgr());
+ MvccSnapshot pin = f.table.loadSnapshot(Optional.empty(), Optional.empty());
+ ctx.getStatementContext().setSnapshot(new MvccTableInfo(f.table), pin);
+ SlotReference slot = new SlotReference("dt", DateV2Type.INSTANCE);
+ LogicalFileScan scan = new LogicalFileScan(new RelationId(11), f.table,
+ Collections.singletonList("db"), Collections.emptyList(), Optional.empty(),
+ Optional.empty(), Optional.empty(), Optional.of(Collections.singletonList(slot)));
+ LogicalFilter> filter = new LogicalFilter<>(
+ Collections.singleton(new EqualTo(slot, new DateV2Literal(2024, 1, 1))), scan);
+ Assertions.assertSame(LogicalFileScan.SelectedPartitions.NATIVE, scan.getSelectedPartitions());
+ // The rewrite framework checks this pattern before invoking transform.
+ Assertions.assertFalse(new PruneFileScanPartition().build().getPattern().matchPlanTree(filter));
+ Assertions.assertEquals(Collections.singleton(new EqualTo(slot, new DateV2Literal(2024, 1, 1))),
+ filter.getConjuncts());
+ Mockito.verify(f.metadata, Mockito.never()).listPartitions(Mockito.any(), Mockito.any(), Mockito.any());
+ }
+
+ @Test
+ public void deferredViewLoadsOnceOnThePinnedHandleAcrossThreads() throws Exception {
+ Fixture f = Fixture.partitioned();
+ nativeContext(f, new SqlBlockRuleMgr());
+ PluginDrivenMvccSnapshot pin = (PluginDrivenMvccSnapshot)
+ f.table.loadSnapshot(Optional.empty(), Optional.empty());
+ // A later latest generation must not change what the deferred loader enumerates.
+ Mockito.when(f.metadata.beginQuerySnapshot(f.session, f.handle)).thenReturn(Optional.of(
+ ConnectorMvccSnapshot.builder().snapshotId(PINNED_SNAPSHOT_ID + 1).build()));
+ Mockito.when(f.metadata.listPartitions(Mockito.eq(f.session), Mockito.eq(f.handle), Mockito.any()))
+ .thenReturn(Collections.singletonList(cpi("dt=2025-01-01", 1L)));
+ ExecutorService pool = Executors.newFixedThreadPool(4);
+ try {
+ List>> results = new ArrayList<>();
+ for (int i = 0; i < 8; i++) {
+ results.add(pool.submit(pin::getNameToPartitionItem));
+ }
+ Map first = results.get(0).get();
+ Assertions.assertEquals(2, first.size());
+ for (Future