This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 1f71a665654 [fix](fe) Keep sibling capabilities on iceberg-on-HMS 
tables pinned at a snapshot (#69999)
1f71a665654 is described below

commit 1f71a665654f60ac5895440a3c86e02ea8a36825
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Nov 3 10:27:49 2026 +0000

    [fix](fe) Keep sibling capabilities on iceberg-on-HMS tables pinned at a 
snapshot (#69999)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69299, #69488
    
    Problem Summary:
    
    #### In short
    
    An Iceberg table stored in HMS and read through an HMS catalog
    (`'type'='hms'`) loses every capability it inherits from the embedded
    Iceberg connector in any statement that pins the table at a snapshot.
    Since #69299 that is every statement that reads it: it gets no Top-N
    lazy materialization and no nested column pruning, and `DEFAULT(column)`
    in the conditions of `UPDATE` and `MERGE` silently matches nothing. This
    PR makes the snapshot overload of `HiveConnectorMetadata#getTableSchema`
    pass the inherited capabilities on, as the latest-schema overload
    already does. It is a master-only fix (branch-4.x has no connector SPI),
    left over from #69488 (review comment 4355508100).
    
    #### Background
    
    fe-core resolves a table-scoped capability
    (`PluginDrivenExternalTable.hasCapability`) from the catalog's connector
    or from the per-table set in the table's schema. It never asks the
    embedded connector that built the schema of an iceberg-on-HMS table. So
    `HiveConnectorMetadata` copies the capabilities that fe-core resolves
    per table (`SIBLING_INHERITABLE_CAPABILITIES`: auto and sampled analyze,
    Top-N lazy materialization, nested column pruning, field-id access
    paths, storage predicate pruning, nested column DDL and write-only
    defaults) from the owning connector into the per-table set of each
    schema it delegates. Iceberg declares seven of the eight; it does not
    declare sampled analyze.
    
    It did this only in `getTableSchema(session, handle)`.
    `getTableSchema(session, handle, snapshot)` returned the sibling's
    schema unchanged. fe-core uses that overload for explicit time travel.
    Since #69299 it also uses it for the latest-snapshot pin of every
    statement that reads an Iceberg table: the pin retains its schema
    (`retainSchema(true)`), `PluginDrivenMvccExternalTable` builds the
    schema at that pin, and `getSchemaCacheValue()`, where `hasCapability`
    reads the per-table set, returns it for the rest of the statement.
    
    #### The problem, and what it cost
    
    Reproduced on a local cluster with a standalone Hive 3.1.2 metastore.
    Two identical format-version 3 tables `(id INT, c INT, s STRUCT<a:INT,
    b:STRING>)` get two rows, then `ADD COLUMN d INT DEFAULT 42`, `RENAME
    COLUMN s.a TO a2` and a third row with `d = 7`. The same statements then
    run against one table through the Iceberg catalog and against the other
    through the HMS catalog.
    
    | Statement | Iceberg catalog | HMS catalog, master | HMS catalog, this
    PR |
    |---|---|---|---|
    | `EXPLAIN SELECT * FROM t ORDER BY id LIMIT 1`, also with `FOR VERSION
    AS OF` | `VMaterializeNode` | no lazy materialization |
    `VMaterializeNode` |
    | `EXPLAIN SELECT struct_element(s, 'a2') FROM t` | `all access paths:
    [s.a2]` | no pruning | `all access paths: [s.a2]` |
    | `UPDATE t SET c = 100 WHERE d = DEFAULT(d)` | updates rows 0 and 1 |
    updates nothing | updates rows 0 and 1 |
    | `MERGE INTO t USING (SELECT 0 AS id) s ON t.id = s.id AND t.d =
    DEFAULT(t.d) WHEN MATCHED THEN UPDATE SET c = 300` | updates row 0 |
    updates nothing | updates row 0 |
    | `DELETE FROM t WHERE d = DEFAULT(d)` | fails: `cannot be used in a
    DELETE` | deletes nothing | fails the same way |
    | `SELECT DEFAULT(d) FROM t` | fails: `must reference a column of the
    table that this statement writes` | returns NULL | fails the same way |
    
    The `UPDATE`, `MERGE` and `DELETE` rows fail silently: the statement
    succeeds and changes nothing. Reads of the renamed field return the
    right values in all three columns: on master the HMS catalog does not
    prune the struct at all, and with this PR it prunes it with field-id
    access paths, as the Iceberg catalog does. `INSERT ... VALUES
    (DEFAULT(d))` and `INSERT ... SELECT DEFAULT(d)` already wrote 42
    through both catalogs.
    
    #### How this PR fixes it
    
    Both overloads now go through one private helper,
    `delegatedTableSchema`. It resolves the owning sibling once, asks it for
    the schema (at the pin, for the snapshot overload), and passes the
    result through the existing `reflectSiblingCapabilities`. The snapshot
    reaches the sibling unchanged. A hudi-on-HMS table is unaffected: the
    Hudi connector declares no capabilities, so its schema comes back as it
    was. A plain Hive table never reaches this branch.
    
    #### Results
    
    Through the HMS catalog, every statement in the table above now plans
    and answers as it does through the Iceberg catalog.
---
 .../connector/hive/HiveConnectorMetadata.java      |  31 ++++--
 ...HiveConnectorMetadataSiblingDelegationTest.java | 118 +++++++++++++++------
 ..._iceberg_on_hms_gateway_pinned_capabilities.out |  39 +++++++
 ...eberg_on_hms_gateway_pinned_capabilities.groovy | 118 +++++++++++++++++++++
 4 files changed, 269 insertions(+), 37 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
index 43ae99f7bb3..700806051df 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java
@@ -556,12 +556,8 @@ public class HiveConnectorMetadata implements 
ConnectorMetadata {
             // delegated schema as a per-table marker so it survives 
delegation (mirrors Trino table-redirection,
             // where the redirected-to connector's capabilities govern the 
table). Only the subset fe-core
             // actually resolves per-table is reflected; a catalog-wide 
capability (view / show-create / mvcc)
-            // would be discarded by the reader, so emitting it would only 
record an unhonoured intent. Resolve
-            // the owner ONCE (getMetadata is not free) and reuse it for the 
schema build and the capability read.
-            SiblingOwner owner = siblingOwnerResolver.apply(handle);
-            ConnectorTableSchema siblingSchema = 
memoizedSiblingMetadata(session, owner.connector(), owner.label())
-                    .getTableSchema(session, handle);
-            return reflectSiblingCapabilities(owner.connector(), 
siblingSchema);
+            // would be discarded by the reader, so emitting it would only 
record an unhonoured intent.
+            return delegatedTableSchema(session, handle, sibling -> 
sibling.getTableSchema(session, handle));
         }
         HiveTableHandle hiveHandle = (HiveTableHandle) handle;
         String dbName = hiveHandle.getDbName();
@@ -651,6 +647,24 @@ public class HiveConnectorMetadata implements 
ConnectorMetadata {
                     ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE,
                     ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS));
 
+    /**
+     * A delegated (iceberg/hudi-on-HMS) table's schema as {@code buildSchema} 
asks its owning sibling for it,
+     * with the capabilities the table inherits from that sibling reflected 
onto it
+     * ({@link #reflectSiblingCapabilities}). Both {@code getTableSchema} 
overloads go through here because
+     * fe-core reads a table's per-table capabilities from whichever of the 
two schemas the statement uses: a
+     * statement that pins the table (explicit time travel, and every read of 
an iceberg table, whose
+     * latest-snapshot pin retains its schema) uses the snapshot overload's, 
so the two must carry the same
+     * capabilities. The owner is resolved ONCE (getMetadata is not free) and 
reused for the schema build and the
+     * capability read.
+     */
+    private ConnectorTableSchema delegatedTableSchema(ConnectorSession 
session, ConnectorTableHandle handle,
+            Function<ConnectorMetadata, ConnectorTableSchema> buildSchema) {
+        SiblingOwner owner = siblingOwnerResolver.apply(handle);
+        ConnectorTableSchema siblingSchema = buildSchema.apply(
+                memoizedSiblingMetadata(session, owner.connector(), 
owner.label()));
+        return reflectSiblingCapabilities(owner.connector(), siblingSchema);
+    }
+
     /**
      * Reflects the {@link #SIBLING_INHERITABLE_CAPABILITIES} subset of the 
owning sibling connector's
      * connector-wide capability set onto a delegated (iceberg/hudi-on-HMS) 
table's schema as per-table
@@ -1598,7 +1612,10 @@ public class HiveConnectorMetadata implements 
ConnectorMetadata {
     public ConnectorTableSchema getTableSchema(ConnectorSession session, 
ConnectorTableHandle handle,
             ConnectorMvccSnapshot snapshot) {
         if (!(handle instanceof HiveTableHandle)) {
-            return siblingMetadata(session, handle).getTableSchema(session, 
handle, snapshot);
+            // The schema a pinned statement reads its per-table capabilities 
from, so it inherits exactly what
+            // the latest-schema overload above does.
+            return delegatedTableSchema(session, handle,
+                    sibling -> sibling.getTableSchema(session, handle, 
snapshot));
         }
         // Hive has no schema-at-snapshot; the SPI default ignores the 
snapshot and returns the latest schema.
         return getTableSchema(session, handle);
diff --git 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
index 77a7b1529eb..6f43622b053 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSiblingDelegationTest.java
@@ -70,6 +70,7 @@ import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.BooleanSupplier;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
@@ -223,6 +224,9 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         elsewhere.put("icebergSiblingMetadata", "the iceberg by-type resolver, 
driven by HiveConnectorSiblingTest");
         elsewhere.put("hudiSiblingMetadata", "the hudi by-type resolver, the 
other half of the same pair");
         elsewhere.put("memoizedSiblingMetadata", "the per-session memo behind 
the two resolvers above");
+        elsewhere.put("delegatedTableSchema",
+                "the body both getTableSchema overloads share, driven through 
each of them by the"
+                        + " capability-reflection cases here");
         elsewhere.put("getTableHandle",
                 "the pivot that PRODUCES a foreign handle rather than 
consuming one, so it cannot be driven"
                         + " with the foreignHandle these cases use; covered by 
HiveConnectorThreeWayRoutingTest");
@@ -512,6 +516,24 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         Assertions.assertTrue(siblingMetadata.calls.isEmpty(), "the sibling 
must not be forwarded a hive handle");
     }
 
+    /** The pin of a statement that reads an iceberg table: its latest 
snapshot, with the schema retained. */
+    private static final ConnectorMvccSnapshot PINNED_SNAPSHOT =
+            
ConnectorMvccSnapshot.builder().snapshotId(7L).schemaId(3L).retainSchema(true).build();
+
+    /**
+     * The schema each getTableSchema overload returns for the foreign handle, 
keyed by the overload. fe-core reads
+     * a table's per-table capabilities from the snapshot overload's schema in 
every statement that pins the table
+     * (explicit time travel, and every read of an iceberg table, whose 
latest-snapshot pin retains its schema) and
+     * from the latest-schema overload's otherwise, so what a delegated schema 
inherits must hold for both.
+     */
+    private Map<String, ConnectorTableSchema> 
schemasFromBothOverloads(HiveConnectorMetadata md) {
+        Map<String, ConnectorTableSchema> schemas = new LinkedHashMap<>();
+        schemas.put("getTableSchema(session, handle)", 
md.getTableSchema(session, foreignHandle));
+        schemas.put("getTableSchema(session, handle, snapshot)",
+                md.getTableSchema(session, foreignHandle, PINNED_SNAPSHOT));
+        return schemas;
+    }
+
     @Test
     public void 
foreignHandleSchemaReflectsSiblingScanCapabilitiesAsPerTableMarker() {
         // Option C: fe-core's PluginDrivenExternalTable.hasCapability reads 
only the CATALOG (hive) connector,
@@ -519,7 +541,8 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         // capabilities onto the delegated schema as a per-table marker, or an 
iceberg-on-HMS table silently loses
         // auto-analyze / Top-N lazy / nested-column prune / storage predicate 
pruning (all declared connector-wide).
         // MUTATION: dropping the reflection -> the returned schema carries no 
marker -> the embedded table drops the
-        // capabilities post-flip -> red here.
+        // capabilities post-flip -> red here. Dropping it from the snapshot 
overload alone is red too: every
+        // statement reading an iceberg-on-HMS table would then plan without 
them.
         Set<ConnectorCapability> siblingCaps = EnumSet.of(
                 ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE,
                 ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE,
@@ -532,24 +555,56 @@ public class HiveConnectorMetadataSiblingDelegationTest {
                 handle -> new SiblingOwner(new 
CapabilityDeclaringSiblingConnector(siblingCaps),
                         SiblingOwner.ICEBERG_LABEL));
 
-        ConnectorTableSchema schema = md.getTableSchema(session, 
foreignHandle);
-        Set<ConnectorCapability> reflected = schema.getTableCapabilities();
-        
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS),
-                "an iceberg-on-HMS table must inherit write-only defaults, or 
DEFAULT(column) on it answers "
-                        + "NULL from the read schema");
-        
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE),
-                "auto-analyze must survive the delegation as a per-table 
capability");
-        
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE),
-                "Top-N lazy must survive the delegation as a per-table 
capability");
-        
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE),
-                "nested-column prune must survive the delegation as a 
per-table capability");
-        
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_FIELD_ID_ACCESS_PATH),
-                "an iceberg-on-HMS table must inherit the field-id access path 
capability, or its nested "
-                        + "access paths stay name-based while BE matches them 
by iceberg field id");
-        
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_STORAGE_PREDICATE_PRUNING),
-                "storage predicate pruning must survive the delegation as a 
per-table capability");
-        Assertions.assertEquals("sibling-generation", 
schema.getWriteMetadataIdentity(),
-                "capability reflection must not discard the sibling's 
write-generation fence");
+        for (Map.Entry<String, ConnectorTableSchema> entry : 
schemasFromBothOverloads(md).entrySet()) {
+            String overload = entry.getKey();
+            ConnectorTableSchema schema = entry.getValue();
+            Set<ConnectorCapability> reflected = schema.getTableCapabilities();
+            
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_WRITE_ONLY_DEFAULTS),
+                    overload + ": an iceberg-on-HMS table must inherit 
write-only defaults, or DEFAULT(column) on "
+                            + "it answers NULL from the read schema");
+            
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE),
+                    overload + ": auto-analyze must survive the delegation as 
a per-table capability");
+            
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE),
+                    overload + ": Top-N lazy must survive the delegation as a 
per-table capability");
+            
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE),
+                    overload + ": nested-column prune must survive the 
delegation as a per-table capability");
+            
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_FIELD_ID_ACCESS_PATH),
+                    overload + ": an iceberg-on-HMS table must inherit the 
field-id access path capability, or "
+                            + "its nested access paths stay name-based while 
BE matches them by iceberg field id");
+            
Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_STORAGE_PREDICATE_PRUNING),
+                    overload + ": storage predicate pruning must survive the 
delegation as a per-table capability");
+            Assertions.assertEquals("sibling-generation", 
schema.getWriteMetadataIdentity(),
+                    overload + ": capability reflection must not discard the 
sibling's write-generation fence");
+        }
+    }
+
+    @Test
+    public void snapshotOverloadForwardsThePinAndResolvesTheOwnerOnce() {
+        // The snapshot overload must hand the sibling the statement's own pin 
(a schema at another snapshot would
+        // describe other columns) and, like the latest-schema overload, 
resolve the owning sibling once for both
+        // the schema build and the capability read.
+        // MUTATION: asking the sibling for its latest schema instead -> the 
pin never reaches it -> red; resolving
+        // the owner a second time for the capability read -> red on the count.
+        CapabilityDeclaringSiblingConnector sibling = new 
CapabilityDeclaringSiblingConnector(
+                
EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE));
+        AtomicInteger resolutions = new AtomicInteger();
+        HiveConnectorMetadata md = new HiveConnectorMetadata(null, 
HiveTestProperties.minimal(), new FakeConnectorContext(),
+                SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED,
+                handle -> {
+                    resolutions.incrementAndGet();
+                    return new SiblingOwner(sibling, 
SiblingOwner.ICEBERG_LABEL);
+                });
+
+        ConnectorTableSchema schema = md.getTableSchema(session, 
foreignHandle, PINNED_SNAPSHOT);
+
+        
Assertions.assertEquals(Collections.singletonList("getTableSchemaAtSnapshot"), 
sibling.metadata.calls,
+                "the snapshot overload must ask the sibling for its schema at 
the pin, not its latest schema");
+        Assertions.assertSame(PINNED_SNAPSHOT, 
sibling.metadata.lastSchemaSnapshot,
+                "the sibling must receive the statement's pin unchanged");
+        
Assertions.assertEquals(EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE),
+                schema.getTableCapabilities(), "the schema at the pin inherits 
the sibling's capability");
+        Assertions.assertEquals(1, resolutions.get(),
+                "the owning sibling is resolved once for the schema build and 
the capability read");
     }
 
     @Test
@@ -570,10 +625,9 @@ public class HiveConnectorMetadataSiblingDelegationTest {
                 handle -> new SiblingOwner(new 
CapabilityDeclaringSiblingConnector(siblingCaps),
                         SiblingOwner.ICEBERG_LABEL));
 
-        ConnectorTableSchema schema = md.getTableSchema(session, 
foreignHandle);
-        
Assertions.assertEquals(EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE),
-                schema.getTableCapabilities(),
-                "only the per-table-resolved subset may be reflected onto a 
delegated table");
+        schemasFromBothOverloads(md).forEach((overload, schema) -> 
Assertions.assertEquals(
+                EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE), 
schema.getTableCapabilities(),
+                overload + ": only the per-table-resolved subset may be 
reflected onto a delegated table"));
 
         // A NON-empty sibling declaring none of the subset must inherit 
nothing.
         HiveConnectorMetadata noneInheritable = new 
HiveConnectorMetadata(null, HiveTestProperties.minimal(),
@@ -581,9 +635,10 @@ public class HiveConnectorMetadataSiblingDelegationTest {
                 handle -> new SiblingOwner(new 
CapabilityDeclaringSiblingConnector(
                         EnumSet.of(ConnectorCapability.SUPPORTS_VIEW, 
ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT)),
                         SiblingOwner.ICEBERG_LABEL));
-        Assertions.assertTrue(noneInheritable.getTableSchema(session, 
foreignHandle)
-                        .getTableCapabilities().isEmpty(),
-                "a sibling declaring only catalog-wide capabilities must 
contribute no per-table capability");
+        schemasFromBothOverloads(noneInheritable).forEach((overload, schema) 
-> Assertions.assertTrue(
+                schema.getTableCapabilities().isEmpty(),
+                overload + ": a sibling declaring only catalog-wide 
capabilities must contribute no per-table "
+                        + "capability"));
     }
 
     @Test
@@ -594,9 +649,9 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         // foreignHandleSchemaReflectsOnlyThePerTableResolvedCapabilitySubset 
above.
         // MUTATION: dropping the isEmpty() early-return and rebuilding the 
schema unconditionally -> red here.
         HiveConnectorMetadata md = withSibling(); // RecordingSiblingConnector 
declares no capabilities
-        ConnectorTableSchema schema = md.getTableSchema(session, 
foreignHandle);
-        Assertions.assertTrue(schema.getTableCapabilities().isEmpty(),
-                "nothing inherited when the sibling declares no capabilities");
+        schemasFromBothOverloads(md).forEach((overload, schema) -> 
Assertions.assertTrue(
+                schema.getTableCapabilities().isEmpty(),
+                overload + ": nothing inherited when the sibling declares no 
capabilities"));
     }
 
     // ============== per-statement sibling-metadata funnel (HMS heterogeneous 
gateway) ==============
@@ -719,6 +774,7 @@ public class HiveConnectorMetadataSiblingDelegationTest {
     /** A sibling {@link Connector} declaring a fixed capability set; its 
metadata returns a marker-less schema. */
     private static final class CapabilityDeclaringSiblingConnector implements 
Connector {
         private final Set<ConnectorCapability> caps;
+        private final RecordingSiblingMetadata metadata = new 
RecordingSiblingMetadata();
 
         CapabilityDeclaringSiblingConnector(Set<ConnectorCapability> caps) {
             this.caps = caps;
@@ -731,7 +787,7 @@ public class HiveConnectorMetadataSiblingDelegationTest {
 
         @Override
         public ConnectorMetadata getMetadata(ConnectorSession session) {
-            return new RecordingSiblingMetadata();
+            return metadata;
         }
     }
 
@@ -772,6 +828,7 @@ public class HiveConnectorMetadataSiblingDelegationTest {
                 "deleteUsingMetadata"));
 
         final List<String> calls = new ArrayList<>();
+        ConnectorMvccSnapshot lastSchemaSnapshot;
         ConnectorExpression lastDeletePredicate;
         String lastBoundWriteMetadataIdentity;
         BooleanSupplier lastCancellation;
@@ -789,6 +846,7 @@ public class HiveConnectorMetadataSiblingDelegationTest {
         public ConnectorTableSchema getTableSchema(ConnectorSession session, 
ConnectorTableHandle handle,
                 ConnectorMvccSnapshot snapshot) {
             calls.add("getTableSchemaAtSnapshot");
+            lastSchemaSnapshot = snapshot;
             return new ConnectorTableSchema("sibling", 
Collections.emptyList(), "iceberg", Collections.emptyMap(),
                     Collections.emptySet(), "sibling-generation");
         }
diff --git 
a/regression-test/data/external_table_p0/iceberg/test_iceberg_on_hms_gateway_pinned_capabilities.out
 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_on_hms_gateway_pinned_capabilities.out
new file mode 100644
index 00000000000..12364a9a205
--- /dev/null
+++ 
b/regression-test/data/external_table_p0/iceberg/test_iceberg_on_hms_gateway_pinned_capabilities.out
@@ -0,0 +1,39 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !iceberg_renamed_nested --
+0      1       x
+1      2       y
+2      3       z
+
+-- !iceberg_renamed_nested_topn --
+0      {"a2":1, "b":"x"}
+1      {"a2":2, "b":"y"}
+
+-- !iceberg_update_where_default --
+0      100     42
+1      100     42
+2      2       7
+
+-- !iceberg_merge_on_default --
+0      300     42
+1      100     42
+2      2       7
+
+-- !gateway_renamed_nested --
+0      1       x
+1      2       y
+2      3       z
+
+-- !gateway_renamed_nested_topn --
+0      {"a2":1, "b":"x"}
+1      {"a2":2, "b":"y"}
+
+-- !gateway_update_where_default --
+0      100     42
+1      100     42
+2      2       7
+
+-- !gateway_merge_on_default --
+0      300     42
+1      100     42
+2      2       7
+
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_on_hms_gateway_pinned_capabilities.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_on_hms_gateway_pinned_capabilities.groovy
new file mode 100644
index 00000000000..4112cfbb646
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_on_hms_gateway_pinned_capabilities.groovy
@@ -0,0 +1,118 @@
+// 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.
+
+// An iceberg table stored in HMS is reachable through a dedicated iceberg 
catalog ('type'='iceberg',
+// 'iceberg.catalog.type'='hms') and through the heterogeneous HMS gateway 
catalog ('type'='hms'). The gateway
+// passes a fixed set of per-table capabilities on from its embedded iceberg 
sibling to such a table (Top-N lazy
+// materialization, nested column pruning, field-id access paths, write-only 
defaults, ...), and fe-core reads
+// them from the schema the statement uses. A statement that reads an iceberg 
table pins its latest snapshot and
+// uses the schema retained with that pin, which the gateway builds through 
the snapshot overload of
+// getTableSchema, as it does for explicit time travel. That overload used to 
return the sibling's schema without
+// the inherited capabilities, so in every such statement an iceberg-on-HMS 
table planned as if it had none.
+//
+// The suite builds two identical tables through the dedicated catalog, then 
drives the same statements through
+// the dedicated catalog against one and through the gateway against the 
other. The plan and error checks are the
+// same for both, and the .out file holds the results of both under paired 
tags, which must be identical.
+suite("test_iceberg_on_hms_gateway_pinned_capabilities", "p0,external") {
+    String enabled = context.config.otherConfigs.get("enableHiveTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable hive test.")
+        return
+    }
+
+    String hmsPort = context.config.otherConfigs.get("hive3HmsPort")
+    String hdfsPort = context.config.otherConfigs.get("hive3HdfsPort")
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String icebergCatalog = "test_iceberg_on_hms_pinned_caps_iceberg"
+    String gatewayCatalog = "test_iceberg_on_hms_pinned_caps_hms"
+    String dbName = "test_iceberg_on_hms_pinned_caps_db"
+
+    sql """drop catalog if exists ${icebergCatalog}"""
+    sql """create catalog ${icebergCatalog} properties (
+        'type'='iceberg',
+        'iceberg.catalog.type'='hms',
+        'hive.metastore.uris' = 'thrift://${externalEnvIp}:${hmsPort}',
+        'fs.defaultFS' = 'hdfs://${externalEnvIp}:${hdfsPort}'
+    );"""
+    sql """drop catalog if exists ${gatewayCatalog}"""
+    sql """create catalog ${gatewayCatalog} properties (
+        'type'='hms',
+        'hive.metastore.uris' = 'thrift://${externalEnvIp}:${hmsPort}',
+        'fs.defaultFS' = 'hdfs://${externalEnvIp}:${hdfsPort}'
+    );"""
+
+    // Built through the dedicated catalog for both runs. The first two rows 
carry d only as the initial default
+    // 42, and their data files still name the renamed struct field s.a2 as a. 
Returns the first snapshot.
+    def createTable = { String tableName ->
+        sql """switch ${icebergCatalog}"""
+        sql """create database if not exists ${dbName}"""
+        sql """use ${dbName}"""
+        sql """drop table if exists ${tableName}"""
+        sql """create table ${tableName} (id INT, c INT, s STRUCT<a:INT, 
b:STRING>)
+               properties ('format-version' = '3')"""
+        sql """insert into ${tableName} values (0, 0, named_struct('a', 1, 
'b', 'x')), (1, 1, named_struct('a', 2, 'b', 'y'))"""
+        sql """alter table ${tableName} add column d INT DEFAULT 42"""
+        sql """alter table ${tableName} rename column s.a TO a2"""
+        sql """insert into ${tableName} (id, c, s, d) values (2, 2, 
named_struct('a2', 3, 'b', 'z'), 7)"""
+        List<List<Object>> snapshots = sql """select snapshot_id from 
${tableName}\$snapshots order by committed_at"""
+        return snapshots[0][0]
+    }
+
+    def runProbe = { String catalogName, String tableName, String tag ->
+        def firstSnapshot = createTable(tableName)
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+
+        // Top-N lazy materialization, under the latest-snapshot pin and under 
explicit time travel.
+        explain {
+            sql """select * from ${tableName} order by id limit 1"""
+            contains "VMaterializeNode"
+            contains "row_ids: [__DORIS_GLOBAL_ROWID_COL__${tableName}]"
+        }
+        explain {
+            sql """select * from ${tableName} for version as of 
${firstSnapshot} order by id limit 1"""
+            contains "VMaterializeNode"
+        }
+        // Nested column pruning. The pruned read of the renamed field needs 
the field-id access path: by name,
+        // the old files would not have s.a2.
+        explain {
+            sql """select struct_element(s, 'a2') from ${tableName}"""
+            contains "all access paths: [s.a2]"
+        }
+        "order_qt_${tag}_renamed_nested" """select id, struct_element(s, 
'a2'), struct_element(s, 'b') from ${tableName}"""
+        "qt_${tag}_renamed_nested_topn" """select id, s from ${tableName} 
order by id limit 2"""
+
+        // Write-only defaults: DEFAULT(d) is the write-default 42 in the 
conditions of UPDATE and MERGE ...
+        sql """update ${tableName} set c = 100 where d = DEFAULT(d)"""
+        "order_qt_${tag}_update_where_default" """select id, c, d from 
${tableName}"""
+        sql """merge into ${tableName} t using (select 0 as id) src on t.id = 
src.id and t.d = DEFAULT(t.d)
+               when matched then update set c = 300"""
+        "order_qt_${tag}_merge_on_default" """select id, c, d from 
${tableName}"""
+        // ... and it is refused where the statement writes no row.
+        test {
+            sql """delete from ${tableName} where d = DEFAULT(d)"""
+            exception "DEFAULT(d) cannot be used in a DELETE"
+        }
+        test {
+            sql """select DEFAULT(d) from ${tableName}"""
+            exception "DEFAULT(d) must reference a column of the table that 
this statement writes"
+        }
+    }
+
+    runProbe(icebergCatalog, "pinned_caps_iceberg", "iceberg")
+    runProbe(gatewayCatalog, "pinned_caps_gateway", "gateway")
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to