diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java index 9d23bde4a32155..320805bacd0faf 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java @@ -128,4 +128,18 @@ default void setCurrentTransaction(ConnectorTransaction txn) { default long allocateTransactionId() { throw new UnsupportedOperationException("transaction id allocation not supported"); } + + /** + * Returns the per-statement scope for this session — a memoization arena that lives exactly as long + * as the current SQL statement, letting a connector load a table (and derive per-statement state) + * once and share that single object across every read + write resolver in the statement. + * + *

The default is {@link ConnectorStatementScope#NONE} (no memoization = load every time), so + * every existing implementation is unaffected. The engine session implementation overrides it with a + * scope hung on the statement, captured at session construction (the request thread, where the + * statement context is reachable) so off-thread scan pumps that reuse the session still reach it.

+ */ + default ConnectorStatementScope getStatementScope() { + return ConnectorStatementScope.NONE; + } } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScope.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScope.java new file mode 100644 index 00000000000000..4c821a4c4de674 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScope.java @@ -0,0 +1,76 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api; + +import java.util.function.Supplier; + +/** + * A per-statement memoization arena, living exactly as long as one SQL statement (read + write). + * + *

It lets a connector load a table (and derive per-statement state) once and share that single + * object across every resolver in the statement — read metadata, scan planning, write shaping, + * begin-write. Reached from a connector via {@link ConnectorSession#getStatementScope()}.

+ * + *

Neutral SPI: the engine owns the physical home (the scope is hung on the engine's per-statement + * context) and the connector stores its own connector-typed values under string keys as opaque + * {@code Object}s — fe-core never sees a connector type. Values are session-bound and reclaimed with + * the statement, never promoted into any cross-session / cross-identity structure.

+ * + *

{@link #NONE} is the off-context default (no live statement: offline planning, tests, and any + * {@link ConnectorSession} that does not override {@link ConnectorSession#getStatementScope()}). It + * never memoizes, so every call runs the loader — byte-identical to loading every time.

+ */ +public interface ConnectorStatementScope { + + /** + * Returns the value cached under {@code key}, computing it with {@code loader} on first access and + * caching it for the rest of the statement. Within one statement the same key returns the same + * instance to every caller; under {@link #NONE} the loader runs on every call. + */ + T computeIfAbsent(String key, Supplier loader); + + /** + * Typed convenience over {@link #computeIfAbsent} for the ONE {@link ConnectorMetadata} a statement uses per + * {@code key}. The engine's metadata funnel builds {@code key} (as {@code "metadata:" + catalogId}, plus the + * owning connector's label for a heterogeneous gateway) and passes a {@code factory} that calls + * {@code Connector#getMetadata(session)}; every read / scan / DDL / MVCC resolver of the statement then shares + * the single memoized instance and the factory runs at most once per statement. Under {@link #NONE} the + * factory runs on every call (byte-identical to building metadata every time). + */ + default ConnectorMetadata getOrCreateMetadata(String key, Supplier factory) { + return computeIfAbsent(key, factory); + } + + /** + * Deterministically closes, at statement end, every value this scope holds that is {@link AutoCloseable} (a + * memoized {@link ConnectorMetadata} is, via {@link java.io.Closeable}). Best-effort and log-and-continue: a + * failure closing one value does not abort the rest. MUST be idempotent (close-once) in implementations — the + * engine fires it from more than one locus (the query-finish callback, and a reused prepared statement's + * per-execution reset). The default is a no-op, so {@link #NONE} — which memoizes nothing — stays inert. + */ + default void closeAll() { + } + + /** The no-op scope: never caches; each call invokes the loader (offline / no-context / tests). */ + ConnectorStatementScope NONE = new ConnectorStatementScope() { + @Override + public T computeIfAbsent(String key, Supplier loader) { + return loader.get(); + } + }; +} diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java index 7846b13bd19ef0..1ba13db82c1b64 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java @@ -169,16 +169,18 @@ private static String rootCauseMessage(Throwable t) { */ HiveConnectorMetadata newMetadata(HmsClient client) { return new HiveConnectorMetadata(client, properties, context, - this::getOrCreateIcebergSibling, this::getOrCreateHudiSibling, this::resolveSiblingOwner, + this::getOrCreateIcebergSibling, this::getOrCreateHudiSibling, this::resolveSiblingOwnerLabeled, fileListingCache); } /** - * Resolves which embedded sibling connector OWNS a foreign (non-hive) table handle, for the per-handle - * gateway seams (the connector-level {@code get*Provider(handle)} below and the ~34 guard-and-forward - * methods in {@link HiveConnectorMetadata}). Asks each sibling's {@link Connector#ownsHandle} — the sibling - * tests its OWN in-loader handle type, which is invisible to the hive loader across the plugin split, so the - * gateway can never {@code instanceof} the foreign handle itself. + * Resolves which embedded sibling connector OWNS a foreign (non-hive) table handle AND its stable owner label, + * for the per-handle gateway seams (the connector-level {@code get*Provider(handle)} below and the + * guard-and-forward methods in {@link HiveConnectorMetadata}). Asks each sibling's {@link Connector#ownsHandle} + * — the sibling tests its OWN in-loader handle type, which is invisible to the hive loader across the plugin + * split, so the gateway can never {@code instanceof} the foreign handle itself. The MATCHED ARM supplies the + * label ({@link SiblingOwner#ICEBERG_LABEL} / {@link SiblingOwner#HUDI_LABEL}), so a per-handle forward keys the + * per-statement metadata funnel by owner without a force-build or an identity comparison against the suppliers. * *

Consults only ALREADY-BUILT siblings (a plain field read, never {@code getOrCreate*}). The owning * sibling is always already built: a foreign handle can only originate from {@code getTableHandle}'s divert, @@ -187,19 +189,28 @@ HiveConnectorMetadata newMetadata(HmsClient client) { * plugin must still route its hudi handles without building an iceberg sibling. Fails loud when no built * sibling owns the handle (an orphan handle is a bug, not a route), naming the catalog. */ - private Connector resolveSiblingOwner(ConnectorTableHandle handle) { + SiblingOwner resolveSiblingOwnerLabeled(ConnectorTableHandle handle) { Connector iceberg = icebergSibling; if (iceberg != null && iceberg.ownsHandle(handle)) { - return iceberg; + return new SiblingOwner(iceberg, SiblingOwner.ICEBERG_LABEL); } Connector hudi = hudiSibling; if (hudi != null && hudi.ownsHandle(handle)) { - return hudi; + return new SiblingOwner(hudi, SiblingOwner.HUDI_LABEL); } throw new DorisConnectorException("Cannot route a foreign table handle in catalog '" + context.getCatalogName() + "': no embedded sibling connector owns it"); } + /** + * The owning sibling {@link Connector} alone (label dropped) for the connector-level per-handle provider seams + * ({@code get*Provider(handle)} below), which route by owner but never touch the metadata funnel. Delegates to + * {@link #resolveSiblingOwnerLabeled} so the 3-way ownsHandle dispatch has a single implementation. + */ + private Connector resolveSiblingOwner(ConnectorTableHandle handle) { + return resolveSiblingOwnerLabeled(handle).connector(); + } + @Override public ConnectorScanPlanProvider getScanPlanProvider() { return new HiveScanPlanProvider(getOrCreateClient(), properties, context, readTxnManager, fileListingCache); @@ -496,6 +507,21 @@ Connector getOrCreateIcebergSibling() { "Cannot serve iceberg-on-HMS tables in catalog '" + context.getCatalogName() + "': the iceberg connector plugin is not available"); } + // Fail-loud invariant guard for the cache-isolation security track: the hive gateway FRONT + // DOOR never declares SUPPORTS_USER_SESSION, so fe-core keys its per-user schema/name cache + // bypass off THIS (front-door) connector's capabilities and would NOT bypass for a delegated + // sibling. The iceberg sibling is forced iceberg.catalog.type=hms (IcebergSiblingProperties + // .synthesize) and can never be REST session=user, so this must hold today. If a future change + // ever let the sibling be session=user, the front-door-only bypass would silently leak + // cross-user metadata — fail here instead. + if (sibling.getCapabilities().contains(ConnectorCapability.SUPPORTS_USER_SESSION)) { + throw new DorisConnectorException( + "iceberg-on-HMS sibling in catalog '" + context.getCatalogName() + + "' unexpectedly declares SUPPORTS_USER_SESSION: the hive gateway front " + + "door is not session=user, so fe-core's per-user schema/name cache bypass " + + "would not trigger and cross-user metadata would leak. The sibling must " + + "stay iceberg.catalog.type=hms (never REST session=user)."); + } icebergSibling = sibling; } } 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 9e8ea66551561b..07f50ca76ca4a2 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 @@ -192,7 +192,7 @@ public class HiveConnectorMetadata implements ConnectorMetadata { // The by-handle owner resolver installed by the 3-arg constructor (hive-only construction). Invoked only when // a NON-hive handle reaches a per-handle guard-and-forward site — which such a construction never does — so it // fails loud instead of NPEing. - private static final Function NO_SIBLING_OWNER = handle -> { + private static final Function NO_SIBLING_OWNER = handle -> { throw new DorisConnectorException("no sibling connector configured for this hive metadata"); }; @@ -218,7 +218,7 @@ public class HiveConnectorMetadata implements ConnectorMetadata { // guard-and-forward methods below. Backed by HiveConnector.resolveSiblingOwner (a 3-way ownsHandle dispatch // over the ALREADY-BUILT iceberg / hudi siblings). Used ONLY through the parent-first Connector / // ConnectorMetadata interfaces — the owning sibling's concrete types are never cast here (cross-loader CCE). - private final Function siblingOwnerResolver; + private final Function siblingOwnerResolver; // Connector-owned directory-listing cache, shared with the scan provider so estimateDataSizeByListingFiles // (the periodic ExternalRowCountCache refresh source) reuses listings a scan warmed and vice versa. Injected // by HiveConnector.newMetadata as the SAME instance; the convenience constructors below build a private @@ -232,7 +232,7 @@ public HiveConnectorMetadata(HmsClient hmsClient, Map properties public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context, Supplier icebergSiblingSupplier, Supplier hudiSiblingSupplier, - Function siblingOwnerResolver) { + Function siblingOwnerResolver) { this(hmsClient, properties, context, icebergSiblingSupplier, hudiSiblingSupplier, siblingOwnerResolver, new HiveFileListingCache(properties)); } @@ -240,7 +240,7 @@ public HiveConnectorMetadata(HmsClient hmsClient, Map properties public HiveConnectorMetadata(HmsClient hmsClient, Map properties, ConnectorContext context, Supplier icebergSiblingSupplier, Supplier hudiSiblingSupplier, - Function siblingOwnerResolver, + Function siblingOwnerResolver, HiveFileListingCache fileListingCache) { this.hmsClient = hmsClient; this.properties = properties; @@ -251,46 +251,69 @@ public HiveConnectorMetadata(HmsClient hmsClient, Map properties this.fileListingCache = fileListingCache; } + /** + * Obtains the sibling's {@link ConnectorMetadata} through the per-statement funnel: within one statement, the + * first forward for a given owner runs {@code owner.getMetadata(session)} and every later forward (read / scan + * / DDL / MVCC / the per-handle {@code beginTransaction} open) reuses that ONE memoized instance — mirroring + * fe-core's own {@code PluginDrivenMetadata} funnel for a plain connector (the sibling's heavy catalog/caches + * live on the single memoized sibling CONNECTOR regardless of this). The key is + * {@code "metadata:" + catalogId + ":" + ownerLabel}: the three connectors of a heterogeneous gateway + * (hive + iceberg + hudi) share ONE catalogId, so the owner label keeps their metadata entries distinct — a + * plain catalogId key would collapse them onto one metadata and misroute. Under a + * {@link org.apache.doris.connector.api.ConnectorStatementScope#NONE NONE} scope (offline / no statement) the + * factory runs on every call — byte-identical to the pre-funnel behavior. Only fe-connector-api types are + * touched, so no fe-core dependency is introduced. The returned metadata and any handle it produces are used + * ONLY through the parent-first SPI interfaces and MUST NOT be cast (the sibling's concrete iceberg/hudi types + * would CCE across the loader split). + */ + private ConnectorMetadata memoizedSiblingMetadata(ConnectorSession session, Connector owner, String ownerLabel) { + String key = "metadata:" + session.getCatalogId() + ":" + ownerLabel; + return session.getStatementScope().getOrCreateMetadata(key, () -> owner.getMetadata(session)); + } + /** * The embedded iceberg sibling's metadata resolved BY TYPE, for the getTableHandle ICEBERG divert only (an - * iceberg-detected table has no handle yet, so the sibling is force-built and asked directly). Obtained fresh - * per call — parity with fe-core, which acquires a ConnectorMetadata per operation; the heavy catalog/caches - * live on the single (memoized) sibling connector, so this is cheap. The returned metadata and any handle it - * produces are used ONLY through the parent-first SPI interfaces and MUST NOT be cast (the sibling's concrete - * iceberg types would CCE across the loader split). + * iceberg-detected table has no handle yet, so the sibling is force-built and asked directly). Routed through + * {@link #memoizedSiblingMetadata} under {@link SiblingOwner#ICEBERG_LABEL}, so the divert and the same + * statement's later per-handle forwards share ONE iceberg metadata instance. The returned metadata and any + * handle it produces are used ONLY through the parent-first SPI interfaces and MUST NOT be cast. * *

Package-private (not private) so HiveConnectorThreeWayRoutingTest can assert that * {@link HiveConnector#getMetadata} wires the iceberg by-TYPE supplier to THIS arm (the two same-typed * supplier args are otherwise transposable at that sole production wiring point). */ ConnectorMetadata icebergSiblingMetadata(ConnectorSession session) { - return icebergSiblingSupplier.get().getMetadata(session); + return memoizedSiblingMetadata(session, icebergSiblingSupplier.get(), SiblingOwner.ICEBERG_LABEL); } /** * The embedded hudi sibling's metadata resolved BY TYPE, for the getTableHandle HUDI divert only (a * hudi-detected table has no handle yet, so the sibling is force-built and asked directly). Same lifecycle and - * casting contract as {@link #icebergSiblingMetadata}: obtained fresh per call, used ONLY through the - * parent-first SPI interfaces, and never cast (the sibling's concrete hudi types would CCE across the loader - * split). + * casting contract as {@link #icebergSiblingMetadata}: routed through {@link #memoizedSiblingMetadata} under + * {@link SiblingOwner#HUDI_LABEL}, used ONLY through the parent-first SPI interfaces, and never cast. * *

Package-private (not private) so HiveConnectorThreeWayRoutingTest can assert that * {@link HiveConnector#getMetadata} wires the hudi by-TYPE supplier to THIS arm (see * {@link #icebergSiblingMetadata}). */ ConnectorMetadata hudiSiblingMetadata(ConnectorSession session) { - return hudiSiblingSupplier.get().getMetadata(session); + return memoizedSiblingMetadata(session, hudiSiblingSupplier.get(), SiblingOwner.HUDI_LABEL); } /** * The OWNING sibling's metadata for a foreign (non-hive) table handle, resolved BY HANDLE (3-way ownsHandle - * dispatch over the already-built iceberg / hudi siblings — see HiveConnector.resolveSiblingOwner). Every - * per-handle guard-and-forward method routes through here so a hudi handle reaches the hudi sibling and an - * iceberg handle the iceberg sibling. Obtained fresh per call; the handle is used ONLY through the - * parent-first SPI interfaces and MUST NOT be cast (cross-loader CCE). + * dispatch over the already-built iceberg / hudi siblings — see HiveConnector.resolveSiblingOwnerLabeled). + * Every per-handle guard-and-forward method (and the per-handle {@code beginTransaction} open) routes through + * here, so a hudi handle reaches the hudi sibling and an iceberg handle the iceberg sibling. The resolver + * supplies the owner label from its matched arm, and {@link #memoizedSiblingMetadata} keys the funnel by it — + * so a statement's forwards for one owner share ONE metadata instance, and the by-HANDLE label matches the + * by-TYPE label above (same owner → same key → the getTableHandle divert and these forwards reuse + * one instance). The handle is used ONLY through the parent-first SPI interfaces and MUST NOT be cast + * (cross-loader CCE). */ private ConnectorMetadata siblingMetadata(ConnectorSession session, ConnectorTableHandle handle) { - return siblingOwnerResolver.apply(handle).getMetadata(session); + SiblingOwner owner = siblingOwnerResolver.apply(handle); + return memoizedSiblingMetadata(session, owner.connector(), owner.label()); } // ========== ConnectorSchemaOps ========== @@ -425,9 +448,10 @@ public ConnectorTableSchema getTableSchema( // capabilities govern the table). Only hasScanCapability consumers read the marker, so a capability // that is not per-table-refinable (view / show-create / mvcc) is inert here. Resolve the owner ONCE // (getMetadata is not free) and reuse it for the schema build and the capability read. - Connector owner = siblingOwnerResolver.apply(handle); - ConnectorTableSchema siblingSchema = owner.getMetadata(session).getTableSchema(session, handle); - return reflectSiblingScanCapabilities(owner, siblingSchema); + SiblingOwner owner = siblingOwnerResolver.apply(handle); + ConnectorTableSchema siblingSchema = memoizedSiblingMetadata(session, owner.connector(), owner.label()) + .getTableSchema(session, handle); + return reflectSiblingScanCapabilities(owner.connector(), siblingSchema); } HiveTableHandle hiveHandle = (HiveTableHandle) handle; String dbName = hiveHandle.getDbName(); diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/SiblingOwner.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/SiblingOwner.java new file mode 100644 index 00000000000000..890b5e9b8f712e --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/SiblingOwner.java @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.Connector; + +/** + * The embedded sibling connector that owns a foreign (iceberg/hudi-on-HMS) table, paired with its stable owner + * label. Produced by the 3-way ownsHandle dispatch ({@code HiveConnector.resolveSiblingOwnerLabeled}) so the + * per-handle gateway seams learn WHICH sibling owns a handle AND its label in a single peek (no force-build, no + * identity comparison). + * + *

The label is the single source of truth for the per-statement metadata funnel key + * ({@code "metadata:" + catalogId + ":" + label}) that lets a statement reuse ONE sibling metadata across all its + * forwards. It MUST be identical on the by-TYPE divert path ({@code getTableHandle}, which has no handle yet and + * asks a sibling by type) and the by-HANDLE forward path (every per-handle guard-and-forward) for the same owner + * — otherwise the two paths would memoize two sibling metadata instances under two keys within one statement. + * Both routes therefore key off {@link #ICEBERG_LABEL} / {@link #HUDI_LABEL} here. + * + *

The connector is held ONLY as the parent-first {@link Connector} interface — never cast (its concrete + * iceberg/hudi types are invisible across the plugin classloader split; a cast would CCE). + */ +final class SiblingOwner { + + /** Owner label for the embedded iceberg sibling — the funnel-key discriminator, shared by both routes. */ + static final String ICEBERG_LABEL = "iceberg"; + /** Owner label for the embedded hudi sibling — the funnel-key discriminator, shared by both routes. */ + static final String HUDI_LABEL = "hudi"; + + private final Connector connector; + private final String label; + + SiblingOwner(Connector connector, String label) { + this.connector = connector; + this.label = label; + } + + Connector connector() { + return connector; + } + + String label() { + return label; + } +} 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 91089872bfc484..c0121cb70fbba0 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 @@ -24,6 +24,7 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.ConnectorTableStatistics; import org.apache.doris.connector.api.ConnectorType; @@ -82,6 +83,12 @@ private static final class ForeignHandle implements ConnectorTableHandle { private final RecordingSiblingMetadata siblingMetadata = new RecordingSiblingMetadata(); private final RecordingSiblingConnector siblingConnector = new RecordingSiblingConnector(siblingMetadata); + // A session whose per-statement scope is NONE (offline): the sibling-metadata funnel runs its factory on every + // forward, so the sibling is consulted per call exactly as before the funnel — these forwarding assertions are + // byte-equivalent to the pre-funnel behavior. Per-statement REUSE (one sibling metadata across many forwards) + // is pinned by the "per-statement sibling-metadata funnel" tests below, which run under a live TestStatementScope. + private final ConnectorSession session = new ScopeSession(1L, "q1", ConnectorStatementScope.NONE); + /** * The by-TYPE force-build supplier constructor arg. This suite exercises only per-handle (by-handle) sites — * which must ALL route via the peek resolver — and never calls getTableHandle (the only by-type site), so the @@ -103,7 +110,8 @@ private static final class ForeignHandle implements ConnectorTableHandle { */ private HiveConnectorMetadata withSibling() { return new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), - SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, handle -> siblingConnector); + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)); } private HiveTableHandle hiveHandle() { @@ -115,34 +123,34 @@ public void everyPerHandleMethodForwardsAForeignHandleToTheSibling() { HiveConnectorMetadata md = withSibling(); // ---- set (a): methods hive overrides — a foreign handle must NOT run hive logic, it must divert ---- - md.getTableSchema(null, foreignHandle); - md.getColumnHandles(null, foreignHandle); - md.getTableStatistics(null, foreignHandle); - md.getColumnStatistics(null, foreignHandle, "c"); - long size = md.estimateDataSizeByListingFiles(null, foreignHandle); - List> timelineRows = md.getMetadataTableRows(null, foreignHandle, "timeline"); - Optional> filter = md.applyFilter(null, foreignHandle, null); - List partNames = md.listPartitionNames(null, foreignHandle); - md.listPartitions(null, foreignHandle, Optional.empty()); - ConnectorMvccSnapshot pin = md.beginQuerySnapshot(null, foreignHandle).orElse(null); - md.getTableFreshness(null, foreignHandle); - md.getPartitionFreshnessMillis(null, foreignHandle, "p"); - md.dropTable(null, foreignHandle); - md.truncateTable(null, foreignHandle, Collections.emptyList()); + md.getTableSchema(session, foreignHandle); + md.getColumnHandles(session, foreignHandle); + md.getTableStatistics(session, foreignHandle); + md.getColumnStatistics(session, foreignHandle, "c"); + long size = md.estimateDataSizeByListingFiles(session, foreignHandle); + List> timelineRows = md.getMetadataTableRows(session, foreignHandle, "timeline"); + Optional> filter = md.applyFilter(session, foreignHandle, null); + List partNames = md.listPartitionNames(session, foreignHandle); + md.listPartitions(session, foreignHandle, Optional.empty()); + ConnectorMvccSnapshot pin = md.beginQuerySnapshot(session, foreignHandle).orElse(null); + md.getTableFreshness(session, foreignHandle); + md.getPartitionFreshnessMillis(session, foreignHandle, "p"); + md.dropTable(session, foreignHandle); + md.truncateTable(session, foreignHandle, Collections.emptyList()); // ---- set (b): methods hive does NOT override — the silent gaps that must be filled by forwarding ---- - md.getTableSchema(null, foreignHandle, null); - md.getMvccPartitionView(null, foreignHandle); - md.resolveTimeTravel(null, foreignHandle, null); - ConnectorTableHandle afterSnapshot = md.applySnapshot(null, foreignHandle, null); - List predicates = md.getSyntheticScanPredicates(null, foreignHandle, null); - ConnectorTableHandle afterScope = md.applyRewriteFileScope(null, foreignHandle, Collections.emptySet()); - ConnectorTableHandle afterTopn = md.applyTopnLazyMaterialization(null, foreignHandle); - List sysTables = md.listSupportedSysTables(null, foreignHandle); - Optional sysHandle = md.getSysTableHandle(null, foreignHandle, "snapshots"); + md.getTableSchema(session, foreignHandle, null); + md.getMvccPartitionView(session, foreignHandle); + md.resolveTimeTravel(session, foreignHandle, null); + ConnectorTableHandle afterSnapshot = md.applySnapshot(session, foreignHandle, null); + List predicates = md.getSyntheticScanPredicates(session, foreignHandle, null); + ConnectorTableHandle afterScope = md.applyRewriteFileScope(session, foreignHandle, Collections.emptySet()); + ConnectorTableHandle afterTopn = md.applyTopnLazyMaterialization(session, foreignHandle); + List sysTables = md.listSupportedSysTables(session, foreignHandle); + Optional sysHandle = md.getSysTableHandle(session, foreignHandle, "snapshots"); // "snapshots" (not "partitions"): hive's own logic returns false for it, so a true answer proves the // reply came from the sibling, not hive. - boolean sysIsTvf = md.isPartitionValuesSysTable(null, foreignHandle, "snapshots"); + boolean sysIsTvf = md.isPartitionValuesSysTable(session, foreignHandle, "snapshots"); // Every per-handle method reached the sibling (proves the divert covers the whole surface). Assertions.assertEquals(RecordingSiblingMetadata.EXPECTED_METHODS, siblingMetadata.calls, @@ -186,24 +194,24 @@ public void hiveHandleRunsHiveBranchAndNeverConsultsSibling() { // The set-(b) + beginQuerySnapshot branches reproduce the SPI default / hive pin WITHOUT the sibling and // without touching the (null) hmsClient — proving the guard falls through to the hive path for a hive handle. - Assertions.assertFalse(md.getMvccPartitionView(null, hive).isPresent(), "hive has no range partition view"); - Assertions.assertFalse(md.resolveTimeTravel(null, hive, null).isPresent(), "hive has no time travel"); - Assertions.assertSame(hive, md.applySnapshot(null, hive, null), "hive applySnapshot returns the handle"); - Assertions.assertTrue(md.getSyntheticScanPredicates(null, hive, null).isEmpty(), + Assertions.assertFalse(md.getMvccPartitionView(session, hive).isPresent(), "hive has no range partition view"); + Assertions.assertFalse(md.resolveTimeTravel(session, hive, null).isPresent(), "hive has no time travel"); + Assertions.assertSame(hive, md.applySnapshot(session, hive, null), "hive applySnapshot returns the handle"); + Assertions.assertTrue(md.getSyntheticScanPredicates(session, hive, null).isEmpty(), "plain hive has no synthetic scan predicate"); - Assertions.assertSame(hive, md.applyRewriteFileScope(null, hive, Collections.emptySet()), + Assertions.assertSame(hive, md.applyRewriteFileScope(session, hive, Collections.emptySet()), "hive applyRewriteFileScope returns the handle"); - Assertions.assertSame(hive, md.applyTopnLazyMaterialization(null, hive), + Assertions.assertSame(hive, md.applyTopnLazyMaterialization(session, hive), "hive applyTopnLazyMaterialization returns the handle"); - Assertions.assertEquals(Collections.singletonList("partitions"), md.listSupportedSysTables(null, hive), + Assertions.assertEquals(Collections.singletonList("partitions"), md.listSupportedSysTables(session, hive), "hive exposes the partitions sys table (t$partitions), served by the partition_values TVF"); - Assertions.assertTrue(md.isPartitionValuesSysTable(null, hive, "partitions"), + Assertions.assertTrue(md.isPartitionValuesSysTable(session, hive, "partitions"), "hive's partitions sys table is TVF-backed"); - Assertions.assertFalse(md.isPartitionValuesSysTable(null, hive, "snapshots"), + Assertions.assertFalse(md.isPartitionValuesSysTable(session, hive, "snapshots"), "hive exposes no sys table other than partitions"); - Assertions.assertFalse(md.getSysTableHandle(null, hive, "snapshots").isPresent(), + Assertions.assertFalse(md.getSysTableHandle(session, hive, "snapshots").isPresent(), "hive's TVF-backed sys table has no native handle"); - ConnectorMvccSnapshot pin = md.beginQuerySnapshot(null, hive).orElse(null); + ConnectorMvccSnapshot pin = md.beginQuerySnapshot(session, hive).orElse(null); Assertions.assertNotNull(pin); Assertions.assertEquals(-1L, pin.getSnapshotId(), "hive's pin is the empty (-1) last-modified pin"); Assertions.assertTrue(pin.isLastModifiedFreshness(), "hive's pin flags last-modified freshness"); @@ -219,7 +227,7 @@ public void foreignHandleFailsLoudWhenNoSiblingConfigured() { // raise a clear error, not NPE deep in a forward. HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext()); - Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableSchema(null, foreignHandle), + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableSchema(session, foreignHandle), "a foreign handle with no sibling configured must fail loud"); } @@ -230,25 +238,25 @@ public void everyAlterDdlAndValidateMethodForwardsAForeignHandleToTheSibling() { // The 14 ALTER-DDL mutators + 2 write validators: a foreign (iceberg-on-HMS) handle must divert, never // run the hive branch and never be cast. Change objects are null — the guard fires on the handle type // before any param is touched. - md.renameTable(null, foreignHandle, "new"); - md.addColumn(null, foreignHandle, null, null); - md.addColumns(null, foreignHandle, Collections.emptyList()); - md.dropColumn(null, foreignHandle, "c"); - md.renameColumn(null, foreignHandle, "a", "b"); - md.modifyColumn(null, foreignHandle, null, null); - md.reorderColumns(null, foreignHandle, Collections.emptyList()); - md.createOrReplaceBranch(null, foreignHandle, null); - md.createOrReplaceTag(null, foreignHandle, null); - md.dropBranch(null, foreignHandle, null); - md.dropTag(null, foreignHandle, null); - md.addPartitionField(null, foreignHandle, null); - md.dropPartitionField(null, foreignHandle, null); - md.replacePartitionField(null, foreignHandle, null); - md.validateRowLevelDmlMode(null, foreignHandle, null); - md.validateStaticPartitionColumns(null, foreignHandle, Collections.emptyList()); + md.renameTable(session, foreignHandle, "new"); + md.addColumn(session, foreignHandle, null, null); + md.addColumns(session, foreignHandle, Collections.emptyList()); + md.dropColumn(session, foreignHandle, "c"); + md.renameColumn(session, foreignHandle, "a", "b"); + md.modifyColumn(session, foreignHandle, null, null); + md.reorderColumns(session, foreignHandle, Collections.emptyList()); + md.createOrReplaceBranch(session, foreignHandle, null); + md.createOrReplaceTag(session, foreignHandle, null); + md.dropBranch(session, foreignHandle, null); + md.dropTag(session, foreignHandle, null); + md.addPartitionField(session, foreignHandle, null); + md.dropPartitionField(session, foreignHandle, null); + md.replacePartitionField(session, foreignHandle, null); + md.validateRowLevelDmlMode(session, foreignHandle, null); + md.validateStaticPartitionColumns(session, foreignHandle, Collections.emptyList()); // Empty list on purpose: a foreign handle must forward REGARDLESS of emptiness (the empty-early-return is // hive-only) — this would fail if the empty check were placed before the foreign-handle divert. - md.validateWritePartitionNames(null, foreignHandle, Collections.emptyList()); + md.validateWritePartitionNames(session, foreignHandle, Collections.emptyList()); Assertions.assertEquals(RecordingSiblingMetadata.EXPECTED_WRITE_METHODS, siblingMetadata.calls, "every ALTER-DDL mutator + write validator must forward a foreign handle to the sibling"); @@ -263,12 +271,12 @@ public void hiveHandleRejectsNonEmptyPartitionNamesWithLegacyMessage() { // partition-NAME list form INSERT ... PARTITION(p1, p2) is unsupported on a hive table. UNLIKE the two // permissive validators, a hive handle here THROWS the EXACT legacy message on a non-empty list. The e2e // test_hive_write_type.groovy asserts on this literal substring, so it must stay byte-identical. - assertThrowsMessage(() -> md.validateWritePartitionNames(null, hive, Arrays.asList("p1", "p2")), + assertThrowsMessage(() -> md.validateWritePartitionNames(session, hive, Arrays.asList("p1", "p2")), "Not support insert with partition spec in hive catalog."); // An empty list (a plain INSERT ... SELECT or a static PARTITION(col='val') INSERT) is legal plain-hive // and MUST return silently — a throw here would newly reject legal writes. - md.validateWritePartitionNames(null, hive, Collections.emptyList()); + md.validateWritePartitionNames(session, hive, Collections.emptyList()); Assertions.assertEquals(0, siblingConnector.getMetadataCount, "a hive handle must never build/consult the iceberg sibling to validate partition names"); @@ -282,26 +290,26 @@ public void hiveHandleAlterDdlThrowsAndValidateIsNoopAndNeverConsultsSibling() { // Group-1: ALTER-DDL for a hive handle throws the EXACT inherited SPI-default message (byte-parity with // pre-override behavior) without building or consulting the sibling. - assertThrowsMessage(() -> md.renameTable(null, hive, "n"), "RENAME TABLE not supported"); - assertThrowsMessage(() -> md.addColumn(null, hive, null, null), "ADD COLUMN not supported"); - assertThrowsMessage(() -> md.addColumns(null, hive, Collections.emptyList()), "ADD COLUMNS not supported"); - assertThrowsMessage(() -> md.dropColumn(null, hive, "c"), "DROP COLUMN not supported"); - assertThrowsMessage(() -> md.renameColumn(null, hive, "a", "b"), "RENAME COLUMN not supported"); - assertThrowsMessage(() -> md.modifyColumn(null, hive, null, null), "MODIFY COLUMN not supported"); - assertThrowsMessage(() -> md.reorderColumns(null, hive, Collections.emptyList()), + assertThrowsMessage(() -> md.renameTable(session, hive, "n"), "RENAME TABLE not supported"); + assertThrowsMessage(() -> md.addColumn(session, hive, null, null), "ADD COLUMN not supported"); + assertThrowsMessage(() -> md.addColumns(session, hive, Collections.emptyList()), "ADD COLUMNS not supported"); + assertThrowsMessage(() -> md.dropColumn(session, hive, "c"), "DROP COLUMN not supported"); + assertThrowsMessage(() -> md.renameColumn(session, hive, "a", "b"), "RENAME COLUMN not supported"); + assertThrowsMessage(() -> md.modifyColumn(session, hive, null, null), "MODIFY COLUMN not supported"); + assertThrowsMessage(() -> md.reorderColumns(session, hive, Collections.emptyList()), "REORDER COLUMNS not supported"); - assertThrowsMessage(() -> md.createOrReplaceBranch(null, hive, null), "CREATE/REPLACE BRANCH not supported"); - assertThrowsMessage(() -> md.createOrReplaceTag(null, hive, null), "CREATE/REPLACE TAG not supported"); - assertThrowsMessage(() -> md.dropBranch(null, hive, null), "DROP BRANCH not supported"); - assertThrowsMessage(() -> md.dropTag(null, hive, null), "DROP TAG not supported"); - assertThrowsMessage(() -> md.addPartitionField(null, hive, null), "ADD PARTITION FIELD not supported"); - assertThrowsMessage(() -> md.dropPartitionField(null, hive, null), "DROP PARTITION FIELD not supported"); - assertThrowsMessage(() -> md.replacePartitionField(null, hive, null), "REPLACE PARTITION FIELD not supported"); + assertThrowsMessage(() -> md.createOrReplaceBranch(session, hive, null), "CREATE/REPLACE BRANCH not supported"); + assertThrowsMessage(() -> md.createOrReplaceTag(session, hive, null), "CREATE/REPLACE TAG not supported"); + assertThrowsMessage(() -> md.dropBranch(session, hive, null), "DROP BRANCH not supported"); + assertThrowsMessage(() -> md.dropTag(session, hive, null), "DROP TAG not supported"); + assertThrowsMessage(() -> md.addPartitionField(session, hive, null), "ADD PARTITION FIELD not supported"); + assertThrowsMessage(() -> md.dropPartitionField(session, hive, null), "DROP PARTITION FIELD not supported"); + assertThrowsMessage(() -> md.replacePartitionField(session, hive, null), "REPLACE PARTITION FIELD not supported"); // Group-2: validate* for a hive handle MUST return silently — a throw here would newly reject legal // plain-hive row-level DML / static-partition INSERTs. - md.validateRowLevelDmlMode(null, hive, null); - md.validateStaticPartitionColumns(null, hive, Collections.emptyList()); + md.validateRowLevelDmlMode(session, hive, null); + md.validateStaticPartitionColumns(session, hive, Collections.emptyList()); Assertions.assertEquals(0, siblingConnector.getMetadataCount, "a hive handle must never build/consult the iceberg sibling for ALTER-DDL / validate"); @@ -315,7 +323,7 @@ public void beginTransactionForwardsAForeignHandleToTheSibling() { // A foreign (iceberg-on-HMS) write must open the SIBLING's transaction, so iceberg's write plan can // downcast the session-bound transaction to IcebergConnectorTransaction — a HiveConnectorTransaction // (what the unconditional open would bind) would ClassCastException there. - ConnectorTransaction txn = md.beginTransaction(null, foreignHandle); + ConnectorTransaction txn = md.beginTransaction(session, foreignHandle); Assertions.assertSame(RecordingSiblingMetadata.SIBLING_TXN, txn, "a foreign handle must open the sibling's transaction, not a hive one"); @@ -332,14 +340,15 @@ public void beginTransactionForHiveHandleOpensHiveTxnAndNeverConsultsSibling() { // sibling. The selection must be symmetric — hive and iceberg write plans downcast to different types. ConnectorTransaction hiveTxn = new NoOpConnectorTransaction(70099L, "HIVE"); HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), - SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, handle -> siblingConnector) { + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)) { @Override public ConnectorTransaction beginTransaction(ConnectorSession session) { return hiveTxn; } }; - Assertions.assertSame(hiveTxn, md.beginTransaction(null, hiveHandle()), + Assertions.assertSame(hiveTxn, md.beginTransaction(session, hiveHandle()), "a hive handle must open the connector-level (hive) transaction, not the sibling's"); Assertions.assertEquals(0, siblingConnector.getMetadataCount, "a hive handle must never build/consult the iceberg sibling to open a transaction"); @@ -360,9 +369,10 @@ public void foreignHandleSchemaReflectsSiblingScanCapabilitiesAsPerTableMarker() ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE); HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, - handle -> new CapabilityDeclaringSiblingConnector(siblingCaps)); + handle -> new SiblingOwner(new CapabilityDeclaringSiblingConnector(siblingCaps), + SiblingOwner.ICEBERG_LABEL)); - ConnectorTableSchema schema = md.getTableSchema(null, foreignHandle); + ConnectorTableSchema schema = md.getTableSchema(session, foreignHandle); String csv = schema.getProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY); Assertions.assertNotNull(csv, "the delegated schema must carry the reflected per-table capability marker"); List names = Arrays.asList(csv.split(",")); @@ -382,7 +392,7 @@ public void foreignHandleSchemaUnchangedWhenSiblingDeclaresNoCapabilities() { // lacks auto-analyze) is pinned by foreignHandleSchemaWithholdsAutoAnalyzeFromRealHudiSibling below. // MUTATION: dropping the isEmpty() early-return and stamping an (empty) marker unconditionally -> red here. HiveConnectorMetadata md = withSibling(); // RecordingSiblingConnector declares no capabilities - ConnectorTableSchema schema = md.getTableSchema(null, foreignHandle); + ConnectorTableSchema schema = md.getTableSchema(session, foreignHandle); Assertions.assertNull(schema.getProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY), "no marker when the sibling declares no capabilities"); } @@ -399,10 +409,10 @@ public void foreignHandleSchemaWithholdsAutoAnalyzeFromRealHudiSibling() { // flag) -> the marker would contain SUPPORTS_COLUMN_AUTO_ANALYZE -> red here. HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, - handle -> new CapabilityDeclaringSiblingConnector( - EnumSet.of(ConnectorCapability.SUPPORTS_METADATA_TABLE))); + handle -> new SiblingOwner(new CapabilityDeclaringSiblingConnector( + EnumSet.of(ConnectorCapability.SUPPORTS_METADATA_TABLE)), SiblingOwner.ICEBERG_LABEL)); - ConnectorTableSchema schema = md.getTableSchema(null, foreignHandle); + ConnectorTableSchema schema = md.getTableSchema(session, foreignHandle); String csv = schema.getProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY); Assertions.assertNotNull(csv, "a non-empty hudi sibling must still stamp its declared capabilities"); List names = Arrays.asList(csv.split(",")); @@ -416,6 +426,102 @@ public void foreignHandleSchemaWithholdsAutoAnalyzeFromRealHudiSibling() { "hudi-on-HMS gains no nested-column prune from a sibling that does not declare it"); } + // ============== per-statement sibling-metadata funnel (HMS heterogeneous gateway) ============== + // Within one statement, the gateway obtains ONE sibling ConnectorMetadata per (catalogId, owner) and reuses it + // across every forward (read / scan / DDL / MVCC / the write-transaction open), keyed + // "metadata::" on the session's per-statement scope — mirroring fe-core's own funnel for + // a plain connector. RecordingSiblingConnector.getMetadataCount is the load count. + + @Test + public void liveScopeSharesOneSiblingMetadataAcrossEveryForwardIncludingTheWriteTxn() { + // Many read forwards (including the getTableSchema stray) + the per-handle beginTransaction open, all in one + // statement -> the sibling is built ONCE and every forward (reads AND the write transaction) reuses it. + // MUTATION: not memoizing -> a build per forward -> count > 1 -> red. + HiveConnectorMetadata md = withSibling(); + ConnectorSession live = new ScopeSession(1L, "q1", new TestStatementScope()); + + md.getColumnHandles(live, foreignHandle); + md.listPartitionNames(live, foreignHandle); + md.getMetadataTableRows(live, foreignHandle, "timeline"); + md.getTableSchema(live, foreignHandle); + md.beginTransaction(live, foreignHandle); + + Assertions.assertEquals(1, siblingConnector.getMetadataCount, + "one sibling metadata per (catalog, owner) per statement — reads and the write txn share it"); + } + + @Test + public void noneScopeBuildsAFreshSiblingMetadataEachForward() { + // No live statement scope (offline / no ConnectContext): the funnel factory runs on every forward, exactly + // as before the funnel existed. This is the byte-equivalence guard for the NONE path. + HiveConnectorMetadata md = withSibling(); + + md.getColumnHandles(session, foreignHandle); + md.listPartitionNames(session, foreignHandle); + md.getColumnHandles(session, foreignHandle); + + Assertions.assertEquals(3, siblingConnector.getMetadataCount, + "NONE scope -> the sibling metadata factory runs on every forward (pre-funnel behavior)"); + } + + @Test + public void byTypeDivertAndByHandleForwardShareOneSiblingMetadata() { + // The getTableHandle divert asks the sibling BY TYPE (icebergSiblingMetadata) before any handle exists; the + // later per-handle forwards resolve BY HANDLE. Both must mint the SAME funnel key for the same owner (the + // by-TYPE literal label == the by-HANDLE resolver-arm label), or the statement would hold two sibling + // metadata instances. Wire the iceberg by-TYPE supplier to the SAME recording connector the resolver + // returns, then drive both paths under one live scope. MUTATION: mismatched labels -> two builds -> red. + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + () -> siblingConnector, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)); + ConnectorSession live = new ScopeSession(1L, "q1", new TestStatementScope()); + + md.icebergSiblingMetadata(live); // by-TYPE (getTableHandle divert path) + md.getColumnHandles(live, foreignHandle); // by-HANDLE (per-handle forward) + + Assertions.assertEquals(1, siblingConnector.getMetadataCount, + "the by-TYPE divert and the by-HANDLE forward key the same owner -> one shared sibling metadata"); + } + + @Test + public void differentCatalogIdIsolatesTheSiblingMetadata() { + // A statement joining two heterogeneous HMS catalogs shares one scope map; the catalog id in the key keeps + // each catalog's sibling metadata isolated. MUTATION: dropping catalogId from the key -> the two collide. + HiveConnectorMetadata md = withSibling(); + TestStatementScope scope = new TestStatementScope(); + + md.getColumnHandles(new ScopeSession(1L, "q1", scope), foreignHandle); + md.getColumnHandles(new ScopeSession(2L, "q1", scope), foreignHandle); + + Assertions.assertEquals(2, siblingConnector.getMetadataCount, + "a different catalog id keys a different sibling metadata (no cross-catalog collapse)"); + } + + @Test + public void icebergAndHudiSiblingsAreIsolatedWithinAStatement() { + // The whole point of the owner label: iceberg-on-HMS and hudi-on-HMS tables of ONE gateway share a catalog + // id, so ONLY the label ("iceberg" vs "hudi") keeps their metadata entries apart. Each owner is built once + // and never collapses onto the other. MUTATION: a shared (label-less) key -> one owner's metadata serves + // the other -> a count is 0 while the other is 2 -> red. + RecordingSiblingConnector hudiConnector = new RecordingSiblingConnector(new RecordingSiblingMetadata()); + ForeignHandle hudiHandle = new ForeignHandle(); + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> handle == hudiHandle + ? new SiblingOwner(hudiConnector, SiblingOwner.HUDI_LABEL) + : new SiblingOwner(siblingConnector, SiblingOwner.ICEBERG_LABEL)); + ConnectorSession live = new ScopeSession(1L, "q1", new TestStatementScope()); + + md.getColumnHandles(live, foreignHandle); // iceberg owner + md.getColumnHandles(live, hudiHandle); // hudi owner + md.getColumnHandles(live, foreignHandle); // iceberg owner again -> reuse + + Assertions.assertEquals(1, siblingConnector.getMetadataCount, + "the iceberg owner is built once under its label"); + Assertions.assertEquals(1, hudiConnector.getMetadataCount, + "the hudi owner is isolated under its own label and built once (never collapsed onto iceberg)"); + } + private static void assertThrowsMessage(Executable exec, String expectedMessage) { DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, exec); Assertions.assertEquals(expectedMessage, e.getMessage(), diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableHandleDivertTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableHandleDivertTest.java index a8996a9ca9c20c..a7aa4830fb39b2 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableHandleDivertTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableHandleDivertTest.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.hms.HmsClient; @@ -68,10 +69,15 @@ public class HiveConnectorMetadataTableHandleDivertTest { // getTableHandle routes BY TYPE (the two by-TYPE suppliers), NEVER via the by-handle owner resolver — so wire // the owner resolver to fail loud if anything reaches for it. - private static final Function OWNER_RESOLVER_UNUSED = handle -> { + private static final Function OWNER_RESOLVER_UNUSED = handle -> { throw new AssertionError("getTableHandle must divert BY TYPE, never via the by-handle owner resolver"); }; + // getTableHandle's by-TYPE divert routes through the per-statement sibling-metadata funnel, which reads + // session.getStatementScope() + getCatalogId(); a NONE scope makes the factory run every call (byte-equivalent + // to the pre-funnel divert), so these by-type routing assertions are unchanged. + private final ConnectorSession session = new ScopeSession(1L, "q1", ConnectorStatementScope.NONE); + /** The foreign (non-hive) handle a sibling's getTableHandle produces post-flip. */ private static final class ForeignHandle implements ConnectorTableHandle { } @@ -85,7 +91,7 @@ private static final class ForeignHandle implements ConnectorTableHandle { public void icebergTableDivertsToIcebergSiblingNotHudi() { HiveConnectorMetadata md = withSiblings(icebergTable()); - Optional handle = md.getTableHandle(null, "db", "t"); + Optional handle = md.getTableHandle(session, "db", "t"); Assertions.assertTrue(handle.isPresent(), "an existing iceberg-on-HMS table must resolve a handle"); Assertions.assertSame(icebergHandle, handle.get(), @@ -106,7 +112,7 @@ public void icebergDivertPropagatesSiblingEmpty() { icebergSibling.metadata.returnHandle = null; HiveConnectorMetadata md = withSiblings(icebergTable()); - Assertions.assertFalse(md.getTableHandle(null, "db", "t").isPresent(), + Assertions.assertFalse(md.getTableHandle(session, "db", "t").isPresent(), "an empty from the sibling must pass through unchanged"); // Prove the empty was FORWARDED from the sibling (its getTableHandle was consulted), not short-circuited // to empty by the gateway — otherwise a broken `if (ICEBERG) return Optional.empty()` would pass this test. @@ -120,7 +126,7 @@ public void hudiTableDivertsToHudiSiblingNotIceberg() { // OWN foreign handle verbatim — NOT a HiveTableHandle stamped HUDI, and NOT routed to the iceberg sibling. HiveConnectorMetadata md = withSiblings(hiveTable(HUDI)); - Optional handle = md.getTableHandle(null, "db", "t"); + Optional handle = md.getTableHandle(session, "db", "t"); Assertions.assertTrue(handle.isPresent(), "an existing hudi-on-HMS table must resolve a handle"); Assertions.assertSame(hudiHandle, handle.get(), @@ -141,7 +147,7 @@ public void hudiDivertPropagatesSiblingEmpty() { hudiSibling.metadata.returnHandle = null; HiveConnectorMetadata md = withSiblings(hiveTable(HUDI)); - Assertions.assertFalse(md.getTableHandle(null, "db", "t").isPresent(), + Assertions.assertFalse(md.getTableHandle(session, "db", "t").isPresent(), "an empty from the hudi sibling must pass through unchanged"); Assertions.assertEquals(1, hudiSibling.metadata.getTableHandleCalls, "the hudi sibling is authoritative for hudi existence: its getTableHandle must be the source of empty"); @@ -151,7 +157,7 @@ public void hudiDivertPropagatesSiblingEmpty() { public void hiveTableBuildsHiveHandleWithoutConsultingSibling() { HiveConnectorMetadata md = withSiblings(hiveTable(PARQUET)); - Optional handle = md.getTableHandle(null, "db", "t"); + Optional handle = md.getTableHandle(session, "db", "t"); Assertions.assertTrue(handle.get() instanceof HiveTableHandle, "a hive table resolves a HiveTableHandle"); Assertions.assertEquals(HiveTableType.HIVE, ((HiveTableHandle) handle.get()).getTableType()); @@ -167,7 +173,7 @@ public void unknownNonViewTableStillFailsLoud() { // non-view table is still rejected (not swallowed into a hudi divert). HiveConnectorMetadata md = withSiblings(hiveTable(UNKNOWN_FORMAT)); - Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(null, "db", "t"), + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(session, "db", "t"), "an unsupported non-view input format must fail loud, not be diverted to a sibling"); Assertions.assertEquals(0, hudiSibling.getMetadataCalls, "the UNKNOWN fail-loud must not consult the hudi sibling"); @@ -181,7 +187,7 @@ public void missingTableReturnsEmptyWithoutConsultingSibling() { new FakeHmsClient(icebergTable(), false), Collections.emptyMap(), new FakeConnectorContext(), () -> icebergSibling, () -> hudiSibling, OWNER_RESOLVER_UNUSED); - Assertions.assertFalse(md.getTableHandle(null, "db", "t").isPresent(), + Assertions.assertFalse(md.getTableHandle(session, "db", "t").isPresent(), "a non-existent table short-circuits to empty before any format detection or divert"); Assertions.assertEquals(0, icebergSibling.getMetadataCalls, "a missing table must not build the iceberg sibling"); Assertions.assertEquals(0, hudiSibling.getMetadataCalls, "a missing table must not build the hudi sibling"); @@ -194,7 +200,7 @@ public void icebergTableFailsLoudWhenNoSiblingConfigured() { HiveConnectorMetadata md = new HiveConnectorMetadata( new FakeHmsClient(icebergTable(), true), Collections.emptyMap(), new FakeConnectorContext()); - Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(null, "db", "t"), + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(session, "db", "t"), "an iceberg table with no sibling configured must fail loud"); } @@ -205,7 +211,7 @@ public void hudiTableFailsLoudWhenNoSiblingConfigured() { HiveConnectorMetadata md = new HiveConnectorMetadata( new FakeHmsClient(hiveTable(HUDI), true), Collections.emptyMap(), new FakeConnectorContext()); - Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(null, "db", "t"), + Assertions.assertThrows(DorisConnectorException.class, () -> md.getTableHandle(session, "db", "t"), "a hudi table with no sibling configured must fail loud"); } diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorSiblingTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorSiblingTest.java index 38f7b8bac79a45..0ff3bd02108a0f 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorSiblingTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorSiblingTest.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.hive; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.DorisConnectorException; @@ -25,8 +26,11 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.Map; +import java.util.Set; /** * Pins the HMS-cutover embedded-sibling holders: a flipped hms gateway lazily builds ONE embedded iceberg @@ -298,6 +302,36 @@ public void refreshHooksNeverForceBuildSiblings() { "invalidate hooks must only forward to ALREADY-BUILT siblings, never force-build one"); } + @Test + public void icebergSiblingWithoutUserSessionCapabilityIsAccepted() { + // The normal case: an hms-flavor sibling declares no SUPPORTS_USER_SESSION, so the fail-loud guard is + // inert and the sibling is returned as-is. + FakeSibling plainSibling = new FakeSibling(); + HiveConnector connector = + new HiveConnector(new HashMap<>(), new RecordingSiblingContext(plainSibling)); + + Assertions.assertSame(plainSibling, connector.getOrCreateIcebergSibling(), + "a sibling without SUPPORTS_USER_SESSION must be accepted"); + } + + @Test + public void icebergSiblingDeclaringUserSessionFailsLoud() { + // Cache-isolation security invariant: the hive gateway FRONT DOOR is never session=user, so fe-core keys + // its per-user schema/name cache bypass off the front door and would NOT bypass for a delegated sibling. + // The iceberg sibling is forced iceberg.catalog.type=hms and can never be session=user; if a future change + // ever broke that, building the sibling must FAIL LOUD rather than silently leak cross-user metadata. + // MUTATION: dropping the guard -> the session=user sibling is accepted -> no throw -> red. + FakeSibling userSessionSibling = + new FakeSibling(EnumSet.of(ConnectorCapability.SUPPORTS_USER_SESSION)); + HiveConnector connector = + new HiveConnector(new HashMap<>(), new RecordingSiblingContext(userSessionSibling)); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, + connector::getOrCreateIcebergSibling); + Assertions.assertTrue(ex.getMessage().contains("SUPPORTS_USER_SESSION"), + "the failure must name the broken session=user invariant"); + } + /** * A bare {@link Connector} stand-in for the cross-loader iceberg/hudi sibling; records lifecycle * ({@code close}) and invalidation forwarding. @@ -307,6 +341,20 @@ private static final class FakeSibling implements Connector { int invalidateAllCount; String lastInvalidatedTable; String lastInvalidatedDb; + private final Set capabilities; + + FakeSibling() { + this(Collections.emptySet()); + } + + FakeSibling(Set capabilities) { + this.capabilities = capabilities; + } + + @Override + public Set getCapabilities() { + return capabilities; + } @Override public ConnectorMetadata getMetadata(ConnectorSession session) { diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorThreeWayRoutingTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorThreeWayRoutingTest.java index e127cac59229c2..e8a85c1830a416 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorThreeWayRoutingTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorThreeWayRoutingTest.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; @@ -192,15 +193,19 @@ public void getMetadataWiresEachByTypeSupplierToItsOwnSibling() { HiveConnector connector = new HiveConnector(props(), ctx); HiveConnectorMetadata md = connector.newMetadata(null); + // A NONE-scope session: the by-TYPE helpers now route through the per-statement metadata funnel (which + // reads session.getStatementScope()/getCatalogId()), so a null session would NPE after the force-build we + // assert. NONE makes the funnel run its factory straight through — the force-build order is unchanged. + ConnectorSession session = new ScopeSession(1L, "q1", ConnectorStatementScope.NONE); // The getTableHandle ICEBERG arm resolves BY TYPE via icebergSiblingMetadata, which force-builds the // iceberg sibling (createSiblingConnector("iceberg")). A transposed getMetadata would build hudi here. - md.icebergSiblingMetadata(null); + md.icebergSiblingMetadata(session); Assertions.assertEquals(1, ctx.icebergBuilds, "the iceberg by-TYPE arm must force-build the iceberg sibling"); Assertions.assertEquals(0, ctx.hudiBuilds, "the iceberg by-TYPE arm must NOT build the hudi sibling"); // Symmetrically the HUDI arm must resolve the hudi sibling, not rebuild iceberg. - md.hudiSiblingMetadata(null); + md.hudiSiblingMetadata(session); Assertions.assertEquals(1, ctx.hudiBuilds, "the hudi by-TYPE arm must force-build the hudi sibling"); Assertions.assertEquals(1, ctx.icebergBuilds, "the hudi by-TYPE arm must not rebuild the iceberg sibling"); } diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/ScopeSession.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/ScopeSession.java new file mode 100644 index 00000000000000..f2e1d6d7c29ff8 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/ScopeSession.java @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.Collections; +import java.util.Map; + +/** + * Minimal {@link ConnectorSession} for hive-connector unit tests, carrying a catalog id and a per-statement + * scope. The heterogeneous gateway keys its per-statement sibling-metadata funnel on + * {@code "metadata:" + getCatalogId() + ":" + ownerLabel} and reads the scope via {@link #getStatementScope()}, + * so a test wires a live {@link TestStatementScope} to prove sharing (or {@link ConnectorStatementScope#NONE} to + * prove the pre-funnel load-every-time behavior). Mirrors the iceberg connector's test session. + */ +final class ScopeSession implements ConnectorSession { + + private final long catalogId; + private final String queryId; + private final ConnectorStatementScope scope; + + ScopeSession(long catalogId, String queryId, ConnectorStatementScope scope) { + this.catalogId = catalogId; + this.queryId = queryId; + this.scope = scope; + } + + @Override + public long getCatalogId() { + return catalogId; + } + + @Override + public String getQueryId() { + return queryId; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return scope; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/TestStatementScope.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/TestStatementScope.java new file mode 100644 index 00000000000000..7d52c162a12dac --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/TestStatementScope.java @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.hive; + +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * A memoizing {@link ConnectorStatementScope} for hive-connector unit tests. The hive connector module does not + * depend on fe-core, so tests cannot use the engine's {@code ConnectorStatementScopeImpl}; this is a faithful + * copy of it (mirrors the iceberg connector's test copy). Sharing one instance across a statement's forwards lets + * a test prove that the heterogeneous gateway reuses ONE sibling metadata per owner per statement. + */ +final class TestStatementScope implements ConnectorStatementScope { + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Supplier loader) { + return (T) cache.computeIfAbsent(key, k -> loader.get()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java new file mode 100644 index 00000000000000..aa179082d3d234 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCommentCache.java @@ -0,0 +1,111 @@ +// 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.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of an iceberg table's {@code comment} property (PERF-05), keyed by {@link TableIdentifier}. + * Kills the per-table remote {@code loadTable} that {@code information_schema.tables} / {@code SHOW TABLE STATUS} + * pays for EVERY table just to read its comment ({@code FrontendServiceImpl.listTableStatus} loops per table and + * unconditionally calls {@code getComment} -> {@link IcebergConnectorMetadata#getTableComment} -> + * {@code loadTable}). + * + *

Scope: REST vended-credentials catalogs ONLY (see {@link IcebergConnector}). Plain catalogs already + * reuse the raw {@link IcebergTableCache} (PERF-01) for the comment path, so this cache is not built for them (no + * redundancy). A {@code iceberg.rest.session=user} catalog is DELIBERATELY excluded: there the {@code loadTable} + * call itself carries the querying user's per-request authorization, and a connector-shared comment cache would + * serve one user's comment to another whose delegated token was never validated for that table (a metadata + * disclosure). Vended-credentials uses a single static catalog identity, so every user loads the same table and + * sharing a comment is safe. + * + *

No credential gate (unlike {@link IcebergTableCache}, like {@link IcebergPartitionCache} / + * {@link IcebergFormatCache}): the cached value is a bare comment {@link String} with no {@code FileIO} / + * credential. A comment changes only via external DDL, picked up through the REFRESH invalidate hooks. TTL is + * {@code meta.cache.iceberg.table.ttl-second}; {@code <= 0} disables (read live), so a no-cache catalog serves a + * fresh comment even when this object is built. Backed identically to {@link IcebergTableCache}: a contextual, + * access-TTL {@link MetaCacheEntry} with manual miss-load, so the remote load runs OUTSIDE Caffeine's compute + * lock and its exception (e.g. the view-handle {@code NoSuchTableException}) propagates verbatim and a failed + * load is not cached. Lives on the long-lived per-catalog {@link IcebergConnector}; a REFRESH CATALOG rebuilds it. + */ +final class IcebergCommentCache { + + private final MetaCacheEntry entry; + + IcebergCommentCache(long ttlSeconds, int maxSize) { + // Mirror IcebergTableCache: translate the connector's "<= 0 disables" contract to CacheSpec's ttl == 0 + // (disabled) rather than passing a negative value through, which CacheSpec reads as ttl == -1 "no + // expiration (enabled)". This ttl<=0 -> disabled mapping is load-bearing: a vended no-cache catalog builds + // this object but must NOT cache comments (operator "no meta cache" intent). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-comment", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always read the comment live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached comment for {@code identifier} if present and unexpired, else runs {@code loader} (the + * live remote {@code loadTable} + property read), caches and returns it. Disabled cache -> {@code loader} + * every call. The loader runs OUTSIDE Caffeine's compute lock (single-flight per key) and its exception + * propagates unwrapped and is NOT cached (the caller degrades a thrown comment load to {@code ""}). + */ + String getOrLoad(TableIdentifier identifier, Supplier loader) { + return entry.get(identifier, ignored -> loader.get()); + } + + /** Drops the cached comment for one table so the next read goes live (REFRESH TABLE). */ + void invalidate(TableIdentifier identifier) { + entry.invalidateKey(identifier); + } + + /** Drops every cached comment for one database (REFRESH DATABASE / DROP DATABASE); match = namespace equality. */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(id -> id.namespace().equals(ns)); + } + + /** Drops all cached comments (REFRESH CATALOG). */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** Test-only: how many times the live loader (the remote comment load) actually ran — the metric gate. */ + long loadCountForTest() { + return entry.stats().getLoadSuccessCount(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java index 08a1670544a285..47fb3dd5abd2ae 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java @@ -158,13 +158,32 @@ public class IcebergConnector implements Connector { // connector (PluginDrivenExternalCatalog.onClose nulls + recreates it) and thus drops both caches. The // manifest cache is path-keyed, no-TTL, capacity-bounded; it is consumed only when // meta.cache.iceberg.manifest.enable is set (default off → scan uses the SDK planFiles path). - private final IcebergLatestSnapshotCache latestSnapshotCache; + // Each cross-query cache below carries an isolation-discipline marker enforced by + // tools/check-authz-cache-sharding.sh: under iceberg.rest.session=user a shared, un-partitioned cache would + // bypass the per-user loadTable authorization (a metadata disclosure), so every cache field must declare + // either 'authz-cache-session-user-disabled' (null under isUserSessionEnabled()) or 'authz-cache-exempt'. + private final IcebergLatestSnapshotCache latestSnapshotCache; // authz-cache-session-user-disabled + // PERF-01: cross-query cache of the RAW iceberg Table (restores the legacy IcebergExternalMetaCache table + // cache that the SPI cutover dropped). null when the catalog's credentials are query-dependent + // (iceberg.rest.session=user / REST vended-credentials) — see the constructor. The per-statement scope + // (ConnectorStatementScope) shares one loaded table across a statement's read/scan/write regardless of this + // field, and is what a credential-gated catalog (this field null) relies on within a statement. + private final IcebergTableCache tableCache; // authz-cache-session-user-disabled + // PERF-02: cross-query partition-view cache (the raw PARTITIONS-scan result, keyed by (table, snapshotId)). + // The value is pure metadata (no FileIO/credential), but under session=user it is an authorization-sensitive + // projection (a shared hit would disclose one user's partitions), so it is disabled there (see constructor). + private final IcebergPartitionCache partitionCache; // authz-cache-session-user-disabled + // PERF-03: cross-query inferred-file-format cache (the whole-table planFiles() fallback result, keyed by + // (table, snapshotId)). Same authorization-sensitive treatment as partitionCache: disabled under session=user. + private final IcebergFormatCache formatCache; // authz-cache-session-user-disabled + // PERF-05: cross-query table-comment cache (value = the 'comment' property string). Built ONLY for a REST + // vended-credentials catalog that is NOT session=user -- plain catalogs already reuse tableCache (PERF-01) for + // the comment path, and session=user must stay live because the loadTable itself carries per-user + // authorization a shared cache would bypass. null for every other flavor. + private final IcebergCommentCache commentCache; // authz-cache-session-user-disabled + // Manifest content cache — pure metadata, default-off (meta.cache.iceberg.manifest.enable), and consumed + // ONLY after a per-user resolveTable(ForRead). authz-cache-exempt (no read path without a per-user load). private final IcebergManifestCache manifestCache = new IcebergManifestCache(); - // commit-bridge supply (S4 part 2): per-catalog stash carrying a row-level DML's non-equality delete supply - // across the scan->write seam — the scan provider fills it (keyed by queryId), the write provider drains it - // into rewritable_delete_file_sets. Like the caches above, a REFRESH CATALOG rebuilds the connector and thus - // drops it. Inert pre-cutover (iceberg scans/writes do not route through the providers until P6.6). - private final IcebergRewritableDeleteStash rewritableDeleteStash = new IcebergRewritableDeleteStash(); // Lazily-built plugin-side Kerberos authenticator (single-owner auth; see TcclPinningConnectorContext). // null for a non-Kerberos catalog. Its doAs acts on the PLUGIN's UserGroupInformation copy — the one the @@ -185,8 +204,56 @@ public IcebergConnector(Map properties, ConnectorContext context // authenticator never logs in — so without this the DDL/read hits secured HDFS as SIMPLE auth. this.context = new TcclPinningConnectorContext(context, getClass().getClassLoader(), this::pluginAuthenticator); - this.latestSnapshotCache = new IcebergLatestSnapshotCache( - resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // Authorization-sensitive projection (snapshotId/schemaId). Under iceberg.rest.session=user the value is + // per-user AUTHORIZED metadata that a "can-list-cannot-load" principal must not see. beginQuerySnapshot + // reads this cache WITHOUT a preceding per-user loadTable, so a shared (table-keyed, no user dimension) + // hit would bypass the per-user authorization that lives inside loadTable (a metadata disclosure). + // Disabled (null) for session=user so beginQuerySnapshot re-loads live per-user every call (no stale-authz + // window); kept for every other flavor (single static identity, no cross-user axis). Mirrors the + // tableCache/commentCache discipline: session=user => no LIVE cross-query metadata cache. + this.latestSnapshotCache = isUserSessionEnabled() + ? null + : new IcebergLatestSnapshotCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-01 cross-query RAW-table cache. Disabled (null) when the catalog's credentials are + // query-dependent, because a cached raw Table carries its FileIO's credentials: + // - iceberg.rest.session=user: per-user delegated FileIO -> sharing across users leaks credentials. + // - REST vended-credentials: the FileIO carries a server-vended token that expires within ~an hour; + // iceberg keeps it fresh by reloading the table each query, so a 24h-TTL hit would hand BE an + // expired token (403 mid-scan). Both gates are independent; either one disables this layer. + // The query-scoped fat handle stays on in all cases (its token is fresh within the one query). Same + // TTL/capacity as the snapshot cache (the single meta.cache.iceberg.table.ttl-second knob). + this.tableCache = (isUserSessionEnabled() + || IcebergScanPlanProvider.restVendedCredentialsEnabled(this.properties)) + ? null + : new IcebergTableCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-02: partition-view cache. Authorization-sensitive projection: a shared (table+snapshot-keyed, no + // user dimension) hit would disclose one user's partition list. Its readers are all downstream of a + // per-user resolveTableForRead today (so a hit cannot precede authz), but that safety rests entirely on + // tableCache being null under session=user. Disabled (null) under session=user makes it safe by + // construction and holds the "session=user => no live cross-query metadata cache" invariant; kept + // otherwise (single static identity). Readers already tolerate a null cache (loadRawPartitions). + this.partitionCache = isUserSessionEnabled() + ? null + : new IcebergPartitionCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-03: inferred-file-format cache. Same authorization-sensitive treatment as partitionCache (disabled + // under session=user, kept otherwise); readers already tolerate a null cache (resolveFileFormatName). + this.formatCache = isUserSessionEnabled() + ? null + : new IcebergFormatCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY); + // PERF-05: table-comment cache, built ONLY for a REST vended-credentials catalog that is NOT session=user. + // Plain catalogs (tableCache on) already serve the comment path from tableCache; session=user is excluded + // because a shared comment cache would bypass the per-user loadTable authorization (a metadata disclosure). + // Comment is pure metadata (no credential) so no gate on the VALUE -- the flavor gate is an authorization + // decision, not a credential-leak one. Same TTL/capacity (ttl<=0 still disables internally). + this.commentCache = (IcebergScanPlanProvider.restVendedCredentialsEnabled(this.properties) + && !isUserSessionEnabled()) + ? new IcebergCommentCache( + resolveTableCacheTtlSecond(this.properties), DEFAULT_TABLE_CACHE_CAPACITY) + : null; } /** @@ -210,8 +277,8 @@ static long resolveTableCacheTtlSecond(Map properties) { @Override public ConnectorMetadata getMetadata(ConnectorSession session) { - return new IcebergConnectorMetadata( - newCatalogBackedOps(session), properties, context, latestSnapshotCache); + return new IcebergConnectorMetadata(newCatalogBackedOps(session), properties, context, + latestSnapshotCache, tableCache, partitionCache, commentCache); } /** @@ -521,7 +588,21 @@ private IcebergCatalogOps newCatalogBackedOps(ConnectorSession session) { */ @Override public void invalidateTable(String dbName, String tableName) { - latestSnapshotCache.invalidate(TableIdentifier.of(dbName, tableName)); + if (latestSnapshotCache != null) { + latestSnapshotCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (tableCache != null) { + tableCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (partitionCache != null) { + partitionCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (formatCache != null) { + formatCache.invalidate(TableIdentifier.of(dbName, tableName)); + } + if (commentCache != null) { + commentCache.invalidate(TableIdentifier.of(dbName, tableName)); + } } /** @@ -537,7 +618,21 @@ public void invalidateTable(String dbName, String tableName) { */ @Override public void invalidateDb(String dbName) { - latestSnapshotCache.invalidateDb(dbName); + if (latestSnapshotCache != null) { + latestSnapshotCache.invalidateDb(dbName); + } + if (tableCache != null) { + tableCache.invalidateDb(dbName); + } + if (partitionCache != null) { + partitionCache.invalidateDb(dbName); + } + if (formatCache != null) { + formatCache.invalidateDb(dbName); + } + if (commentCache != null) { + commentCache.invalidateDb(dbName); + } } /** @@ -549,7 +644,21 @@ public void invalidateDb(String dbName) { */ @Override public void invalidateAll() { - latestSnapshotCache.invalidateAll(); + if (latestSnapshotCache != null) { + latestSnapshotCache.invalidateAll(); + } + if (tableCache != null) { + tableCache.invalidateAll(); + } + if (partitionCache != null) { + partitionCache.invalidateAll(); + } + if (formatCache != null) { + formatCache.invalidateAll(); + } + if (commentCache != null) { + commentCache.invalidateAll(); + } manifestCache.invalidateAll(); } @@ -579,6 +688,31 @@ IcebergManifestCache manifestCacheForTest() { return manifestCache; } + /** Test-only: the cross-query table cache, or {@code null} when disabled by the credential gate (PERF-01). */ + IcebergTableCache tableCacheForTest() { + return tableCache; + } + + /** Test-only: the latest-snapshot cache, or {@code null} when disabled for a session=user catalog. */ + IcebergLatestSnapshotCache latestSnapshotCacheForTest() { + return latestSnapshotCache; + } + + /** Test-only: the cross-query partition-view cache (PERF-02), or {@code null} for a session=user catalog. */ + IcebergPartitionCache partitionCacheForTest() { + return partitionCache; + } + + /** Test-only: the cross-query inferred-file-format cache (PERF-03), or {@code null} for a session=user catalog. */ + IcebergFormatCache formatCacheForTest() { + return formatCache; + } + + /** Test-only: the table-comment cache (PERF-05), or {@code null} unless vended-credentials and non-session. */ + IcebergCommentCache commentCacheForTest() { + return commentCache; + } + @Override public ConnectorScanPlanProvider getScanPlanProvider() { // Mirrors PaimonConnector.getScanPlanProvider: build a fresh provider per call over the lazily-built @@ -588,7 +722,7 @@ public ConnectorScanPlanProvider getScanPlanProvider() { // threaded for parity with the legacy single per-catalog IcebergMetadataOps. return new IcebergScanPlanProvider(properties, this::newCatalogBackedOps, context, manifestCache, - rewritableDeleteStash); + tableCache, formatCache); } @Override @@ -598,8 +732,7 @@ public ConnectorWritePlanProvider getWritePlanProvider() { // IcebergConnectorTransaction. It resolves the target via catalogOps.loadTable, so it shares the // fully-threaded ops (newCatalogBackedOps) — external_catalog.name must apply to INSERT/DELETE/MERGE. return new IcebergWritePlanProvider(properties, - this::newCatalogBackedOps, context, - rewritableDeleteStash); + this::newCatalogBackedOps, context); } @Override 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 35027f5578f1dd..70f5d0a146e056 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 @@ -144,18 +144,54 @@ public class IcebergConnectorMetadata implements ConnectorMetadata { // IcebergExternalMetaCache parity, mirrors paimon). The 3-arg ctor (direct-construction tests) passes a // DISABLED cache so those reads stay always-live. private final IcebergLatestSnapshotCache latestSnapshotCache; + // PERF-01: cross-query RAW-table cache (null = no cross-query layer). The 3-arg direct-construction tests + // and the credential-gated catalogs pass null; the query-scoped fat handle (IcebergTableHandle) works + // regardless. Consumed only by resolveTableForRead. + private final IcebergTableCache tableCache; + // PERF-02: cross-query partition-view cache (null = no cross-query layer; the convenience ctors used by + // direct-construction tests pass null). Consumed by getMvccPartitionView / listPartitions / listPartitionNames. + private final IcebergPartitionCache partitionCache; + // PERF-05: cross-query table-comment cache (null = no cross-query layer). Non-null only when the owning + // connector is a REST vended-credentials, non-session catalog (see IcebergConnector); the convenience ctors + // used by direct-construction tests pass null. Consumed only by getTableComment. + private final IcebergCommentCache commentCache; public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, ConnectorContext context) { - this(catalogOps, properties, context, new IcebergLatestSnapshotCache(0L, 1)); + this(catalogOps, properties, context, new IcebergLatestSnapshotCache(0L, 1), null, null); } + /** Convenience ctor without a cross-query table cache (tableCache null); used by MVCC/statistics tests. */ public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache) { + this(catalogOps, properties, context, latestSnapshotCache, null, null); + } + + /** Convenience ctor without a partition-view cache (partitionCache null). */ + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache, + IcebergTableCache tableCache) { + this(catalogOps, properties, context, latestSnapshotCache, tableCache, null); + } + + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache, + IcebergTableCache tableCache, IcebergPartitionCache partitionCache) { + this(catalogOps, properties, context, latestSnapshotCache, tableCache, partitionCache, null); + } + + /** Full ctor used by {@link IcebergConnector#getMetadata}, adding the PERF-05 table-comment cache. */ + public IcebergConnectorMetadata(IcebergCatalogOps catalogOps, Map properties, + ConnectorContext context, IcebergLatestSnapshotCache latestSnapshotCache, + IcebergTableCache tableCache, IcebergPartitionCache partitionCache, + IcebergCommentCache commentCache) { this.catalogOps = catalogOps; this.properties = properties; this.context = context; this.latestSnapshotCache = latestSnapshotCache; + this.tableCache = tableCache; + this.partitionCache = partitionCache; + this.commentCache = commentCache; } // ========== ConnectorSchemaOps ========== @@ -310,7 +346,20 @@ public String getTableComment(ConnectorSession session, String dbName, String ta // Comment column would all be blank even though the raw comment key still appears in the SHOW CREATE // PROPERTIES(...) block (F9/F12). Views render their comment through getViewDefinition / the view // SHOW CREATE arm, so a view handle here (loadTable throws) falls back to "" via the caller's catch. - Table table = loadTable(new IcebergTableHandle(dbName, tableName)); + // PERF-05: on a vended-credentials (non-session) catalog, memoize the comment per table across queries so + // the per-table loadTable that information_schema.tables / SHOW TABLE STATUS pays for EVERY table collapses + // on repeats. commentCache is null for every other flavor (plain catalogs reuse tableCache via loadTable; + // session=user must stay live to preserve per-user authorization) -> resolve directly. A thrown load (view + // handle) is not cached and propagates to the caller's catch (still ""), so behavior is unchanged. + if (commentCache != null) { + return commentCache.getOrLoad(TableIdentifier.of(dbName, tableName), + () -> loadTableComment(session, dbName, tableName)); + } + return loadTableComment(session, dbName, tableName); + } + + private String loadTableComment(ConnectorSession session, String dbName, String tableName) { + Table table = loadTable(session, new IcebergTableHandle(dbName, tableName)); return table.properties().getOrDefault(TABLE_COMMENT_PROP, ""); } @@ -345,7 +394,7 @@ public ConnectorTableSchema getTableSchema( } // Mirror legacy IcebergMetadataOps.loadTable: wrap the remote load in the auth context. The schema // + table-property assembly is pure (operates on the already-loaded Table). - Table table = loadTable(iceHandle); + Table table = loadTable(session, iceHandle); return buildTableSchema(iceHandle.getTableName(), table, table.schema()); } @@ -370,7 +419,7 @@ public ConnectorTableSchema getTableSchema( if (snapshot == null || snapshot.getSchemaId() < 0) { return getTableSchema(session, handle); } - Table table = loadTable(iceHandle); + Table table = loadTable(session, iceHandle); Schema schema; if (table.currentSnapshot() == null) { // Empty table: legacy getSchema falls back to the latest schema (NEWEST_SCHEMA_ID path). @@ -536,16 +585,38 @@ static String buildShowSortClause(Table table) { return "ORDER BY (" + String.join(", ", sortItems) + ")"; } - /** Loads the iceberg {@link Table} through the seam, wrapped in the FE-injected auth context (Kerberos UGI). */ - private Table loadTable(IcebergTableHandle handle) { + /** + * Loads the iceberg {@link Table} for {@code handle}, wrapped in the FE-injected auth context (Kerberos + * UGI). Resolution goes through {@link #resolveTableForRead} (fat handle -> cross-query cache -> + * remote), so the many reads sharing one handle in a planning/analysis pass collapse onto a single remote + * {@code loadTable} (PERF-01); a fat-handle hit returns without any remote call. + */ + private Table loadTable(ConnectorSession session, IcebergTableHandle handle) { try { - return context.executeAuthenticated( - () -> catalogOps.loadTable(handle.getDbName(), handle.getTableName())); + return context.executeAuthenticated(() -> resolveTableForRead(session, handle)); } catch (Exception e) { throw new RuntimeException("Failed to load table, error message is:" + e.getMessage(), e); } } + /** + * Resolves the RAW iceberg {@link Table} for {@code handle}, WITHOUT opening an auth scope or wrapping + * exceptions — callers own both. The per-statement scope ({@link IcebergStatementScope#sharedTable}) comes + * first, so the statement's read metadata, scan planning and write all resolve the SAME one loaded object; + * on a scope miss the loader consults the cross-query {@link IcebergTableCache} when enabled (else a direct + * remote load). The remote loader's exception propagates verbatim (the cache re-throws it unwrapped), so a + * caller's own {@code NoSuchTableException} degradation (the partition-view readers) still fires. Callers + * needing the auth scope wrap the call in {@code executeAuthenticated} (see {@link #loadTable}). NOT used by + * the sys-table path ({@link #loadSysTable}), which takes a fresh remote base by design. + */ + private Table resolveTableForRead(ConnectorSession session, IcebergTableHandle handle) { + return IcebergStatementScope.sharedTable(session, handle.getDbName(), handle.getTableName(), + () -> tableCache != null + ? tableCache.getOrLoad(TableIdentifier.of(handle.getDbName(), handle.getTableName()), + () -> catalogOps.loadTable(handle.getDbName(), handle.getTableName())) + : catalogOps.loadTable(handle.getDbName(), handle.getTableName())); + } + /** * Loads the iceberg metadata (system) table for {@code handle} through the seam, wrapped in the * FE-injected auth context (Kerberos UGI). Mirrors legacy @@ -584,7 +655,7 @@ public Map getColumnHandles( // Mirror getTableSchema: wrap the remote load in the auth context. A sys handle resolves the // metadata-table columns (t$snapshots -> committed_at/...) so the generic scan node can look up // its pruned sys-table slots by name; a data handle resolves the base table's columns. - Table table = iceHandle.isSystemTable() ? loadSysTable(iceHandle) : loadTable(iceHandle); + Table table = iceHandle.isSystemTable() ? loadSysTable(iceHandle) : loadTable(session, iceHandle); List fields = table.schema().columns(); Map handles = new LinkedHashMap<>(fields.size()); for (Types.NestedField field : fields) { @@ -621,7 +692,7 @@ public Optional getTableStatistics( } long rowCount; try { - rowCount = computeRowCount(loadTable(iceHandle)); + rowCount = computeRowCount(loadTable(session, iceHandle)); } catch (Exception e) { LOG.warn("Failed to compute Iceberg row count for {}.{}", iceHandle.getDbName(), iceHandle.getTableName(), e); @@ -1372,7 +1443,7 @@ public void validateRowLevelDmlMode(ConnectorSession session, ConnectorTableHand default: return; } - Table table = loadTable((IcebergTableHandle) handle); + Table table = loadTable(session, (IcebergTableHandle) handle); String mode = table.properties().getOrDefault(modeProperty, defaultMode); if (RowLevelOperationMode.COPY_ON_WRITE.modeName().equalsIgnoreCase(mode)) { throw new DorisConnectorException(String.format( @@ -1397,7 +1468,7 @@ public void validateStaticPartitionColumns(ConnectorSession session, ConnectorTa return; } IcebergTableHandle iceHandle = (IcebergTableHandle) handle; - Table table = loadTable(iceHandle); + Table table = loadTable(session, iceHandle); PartitionSpec spec = table.spec(); String tableName = iceHandle.getTableName(); if (!spec.isPartitioned()) { @@ -1450,8 +1521,9 @@ public Optional getMvccPartitionView( IcebergTableHandle iceHandle = (IcebergTableHandle) handle; try { return context.executeAuthenticated(() -> { - Table table = catalogOps.loadTable(iceHandle.getDbName(), iceHandle.getTableName()); - return Optional.of(IcebergPartitionUtils.buildMvccPartitionView(table, iceHandle.getSnapshotId())); + Table table = resolveTableForRead(session, iceHandle); + return Optional.of(IcebergPartitionUtils.buildMvccPartitionView(table, iceHandle.getSnapshotId(), + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache)); }); } catch (Exception e) { throw new RuntimeException("Failed to build iceberg MVCC partition view, error message is:" @@ -1473,13 +1545,14 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH return context.executeAuthenticated(() -> { Table table; try { - table = catalogOps.loadTable(iceHandle.getDbName(), iceHandle.getTableName()); + table = resolveTableForRead(session, iceHandle); } catch (NoSuchTableException e) { LOG.warn("Iceberg table not found while listing partitions: {}.{}", iceHandle.getDbName(), iceHandle.getTableName(), e); return Collections.emptyList(); } - return IcebergPartitionUtils.listPartitionNames(table); + return IcebergPartitionUtils.listPartitionNames(table, + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache); }); } catch (Exception e) { throw new RuntimeException("Failed to list iceberg partition names, error message is:" @@ -1504,13 +1577,14 @@ public List listPartitions(ConnectorSession session, return context.executeAuthenticated(() -> { Table table; try { - table = catalogOps.loadTable(iceHandle.getDbName(), iceHandle.getTableName()); + table = resolveTableForRead(session, iceHandle); } catch (NoSuchTableException e) { LOG.warn("Iceberg table not found while listing partitions: {}.{}", iceHandle.getDbName(), iceHandle.getTableName(), e); return Collections.emptyList(); } - return IcebergPartitionUtils.listPartitions(table); + return IcebergPartitionUtils.listPartitions(table, + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), partitionCache); }); } catch (Exception e) { throw new RuntimeException("Failed to list iceberg partitions, error message is:" @@ -1537,16 +1611,30 @@ public Optional beginQuerySnapshot( ConnectorSession session, ConnectorTableHandle handle) { IcebergTableHandle iceHandle = (IcebergTableHandle) handle; TableIdentifier id = TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()); - IcebergLatestSnapshotCache.CachedSnapshot pin = latestSnapshotCache.getOrLoad(id, () -> { - Table table = loadTable(iceHandle); - Snapshot current = table.currentSnapshot(); - return new IcebergLatestSnapshotCache.CachedSnapshot( - current == null ? -1L : current.snapshotId(), table.schema().schemaId()); - }); + // A null latestSnapshotCache (iceberg.rest.session=user, where a shared table-keyed hit would bypass the + // 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 (ttl<=0) + // is a non-null cache that reads live internally. + IcebergLatestSnapshotCache.CachedSnapshot pin = latestSnapshotCache != null + ? latestSnapshotCache.getOrLoad(id, () -> loadLatestSnapshotPin(session, iceHandle)) + : loadLatestSnapshotPin(session, iceHandle); return Optional.of( ConnectorMvccSnapshot.builder().snapshotId(pin.snapshotId).schemaId(pin.schemaId).build()); } + /** + * Loads the table live and pins its latest snapshot id (or {@code -1} for an empty table) plus its LATEST + * schema id — the {@link #beginQuerySnapshot} loader, extracted so the per-user (null-cache) path can reuse + * the exact same pin logic without a cache round-trip. + */ + private IcebergLatestSnapshotCache.CachedSnapshot loadLatestSnapshotPin( + ConnectorSession session, IcebergTableHandle iceHandle) { + Table table = loadTable(session, iceHandle); + Snapshot current = table.currentSnapshot(); + return new IcebergLatestSnapshotCache.CachedSnapshot( + current == null ? -1L : current.snapshotId(), table.schema().schemaId()); + } + /** * Resolves an explicit time-travel {@code spec} into a pinned {@link ConnectorMvccSnapshot}, owning ALL * iceberg-specific parsing. Mirrors legacy {@code IcebergUtils.getQuerySpecSnapshot}, returning @@ -1571,7 +1659,7 @@ public Optional beginQuerySnapshot( @Override public Optional resolveTimeTravel( ConnectorSession session, ConnectorTableHandle handle, ConnectorTimeTravelSpec spec) { - Table table = loadTable((IcebergTableHandle) handle); + Table table = loadTable(session, (IcebergTableHandle) handle); switch (spec.getKind()) { case SNAPSHOT_ID: { long id = Long.parseLong(spec.getStringValue()); diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java index dc6342b0fef3c3..b850f84c63fdc4 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorTransaction.java @@ -205,7 +205,11 @@ public void beginWrite(ConnectorSession session, String db, String tableName, Ic this.zone = IcebergTimeUtils.resolveSessionZone(session); try { context.executeAuthenticated(() -> { - Table loaded = catalogOps.loadTable(db, tableName); + // PERF-07: resolve the table through the per-statement scope so a row-level DML reuses the SAME + // one object the scan already loaded (a write-only INSERT loads once here). openTransaction still + // issues newTransaction()'s refresh, giving the commit a fresh OCC base off that shared object. + Table loaded = IcebergStatementScope.sharedTable(session, db, tableName, + () -> catalogOps.loadTable(db, tableName)); this.table = loaded; applyBeginGuards(ctx, tableName); this.transaction = openTransaction(loaded); diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java new file mode 100644 index 00000000000000..294b29b3738e8c --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergFormatCache.java @@ -0,0 +1,145 @@ +// 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.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.Objects; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of an iceberg table's inferred write file-format name (PERF-03), keyed by + * {@code (TableIdentifier, snapshotId)}. Kills the per-query revival of the #64134 heavy fallback: when a table + * carries neither the {@code write-format} nickname nor {@code write.format.default} (migrated tables, and any + * table whose writer never set the property — parquet is an implicit default, not stored), + * {@link IcebergScanPlanProvider#getScanNodeProperties} resolves the scan-level {@code file_format_type} through + * {@link IcebergWriterHelper#getFileFormat}, which falls back to an unfiltered {@code table.newScan().planFiles()} + * — a whole-table manifest scan (remote FileIO) — just to read the FIRST data file's format. That fallback was + * re-run on every query/EXPLAIN with no cross-query reuse; this cache collapses it to one scan per + * {@code (table, snapshot)}. + * + *

Snapshot-keyed, so always correct. A snapshot is immutable, so its first data file's format is a + * pure function of the key; a new commit yields a new snapshot id (a new key -> a live scan). Within the TTL + * the snapshot id itself is held stable by {@link IcebergLatestSnapshotCache}, which is what makes the key stable + * across queries. The key snapshot is the table's {@code currentSnapshot().snapshotId()} — exactly what the + * inference reads (it scans {@code table.newScan()}, never the handle's time-travel pin), matching legacy + * {@code IcebergUtils.getFileFormat} and PERF-02's partition cache verbatim. + * + *

Only the inference fallback is cached. The two cheap property probes ({@code write-format}, + * {@code write.format.default}) stay outside this cache — the caller consults it only when both miss. + * + *

No credential gate (unlike {@link IcebergTableCache}, like {@link IcebergPartitionCache}): the cached + * value is a bare format-name {@link String} with no {@code FileIO} / credential, so it is safe to share across + * users and is built unconditionally (only the TTL knob disables it). + * + *

Backed identically to {@link IcebergPartitionCache}: a contextual, access-TTL {@link MetaCacheEntry} with + * manual miss-load, so the inference runs OUTSIDE Caffeine's compute lock and a failed scan's exception + * propagates verbatim and is NOT cached (the next query retries — legacy parity). TTL is + * {@code meta.cache.iceberg.table.ttl-second}; {@code <= 0} disables (read live). Lives on the long-lived + * per-catalog {@link IcebergConnector}; a REFRESH CATALOG rebuilds the connector and thus the cache. + */ +final class IcebergFormatCache { + + /** Immutable composite key: a table's inferred format is distinct per pinned snapshot id. */ + static final class Key { + final TableIdentifier id; + final long snapshotId; + + Key(TableIdentifier id, long snapshotId) { + this.id = id; + this.snapshotId = snapshotId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Key)) { + return false; + } + Key that = (Key) o; + return snapshotId == that.snapshotId && Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id, snapshotId); + } + } + + private final MetaCacheEntry entry; + + IcebergFormatCache(long ttlSeconds, int maxSize) { + // Mirror IcebergPartitionCache: translate the connector's "<= 0 disables" contract to CacheSpec's ttl == 0 + // (disabled) rather than passing a negative value through (which CacheSpec reads as "no expiration"). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-format", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always infer live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached format name for {@code key} if present and unexpired, else runs {@code loader} (the live + * whole-table format inference), caches and returns it. Disabled cache -> {@code loader} every call. The + * loader runs OUTSIDE Caffeine's compute lock (single-flight per key) and its exception propagates unwrapped + * and is NOT cached. + */ + String getOrLoad(Key key, Supplier loader) { + return entry.get(key, ignored -> loader.get()); + } + + /** Drops every cached snapshot entry for one table so the next read infers live (REFRESH TABLE). */ + void invalidate(TableIdentifier id) { + entry.invalidateIf(key -> key.id.equals(id)); + } + + /** Drops every cached entry for one database (REFRESH DATABASE / DROP DATABASE); db match = namespace equality. */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(key -> key.id.namespace().equals(ns)); + } + + /** Drops all cached entries (REFRESH CATALOG). */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** Test-only: how many times the live loader (the whole-table format inference) actually ran — the metric gate. */ + long loadCountForTest() { + return entry.stats().getLoadSuccessCount(); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java index 689f1e7722dad4..f3c46732fb2fc1 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergManifestCache.java @@ -73,7 +73,7 @@ final class IcebergManifestCache { // (ConnectorSession.getQueryId()), so VERBOSE EXPLAIN can report THIS scan's hits/misses/failures (the // "manifest cache:" line). The provider that PLANS the scan and the (transient, fresh-per-call) provider that // renders EXPLAIN are different instances, so per-scan state cannot live on the provider; the long-lived - // per-catalog cache is their only shared survivor — same rationale as IcebergRewritableDeleteStash. + // per-catalog cache is their only shared survivor (the queryId keys this per-scan tally onto it). // {@link #takeStats} is the primary eviction (EXPLAIN drains its entry); a non-EXPLAIN query records but // never drains, so its leaked entry is aged out by the lazy TTL sweep in {@link #touch}. private final Map statsByQuery = new ConcurrentHashMap<>(); diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java new file mode 100644 index 00000000000000..3d68ab664cfb87 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPartitionCache.java @@ -0,0 +1,142 @@ +// 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.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; +import org.apache.doris.connector.iceberg.IcebergPartitionUtils.IcebergRawPartition; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of an iceberg table's raw partition list (PERF-02), keyed by {@code (TableIdentifier, + * snapshotId)}. Restores the partition-info half of the legacy {@code IcebergExternalMetaCache} that the SPI + * cutover dropped: the analysis-phase PARTITIONS metadata-table scan + * ({@link IcebergPartitionUtils#loadRawPartitionsUncached}, which the iceberg SDK materializes by reading EVERY + * data+delete manifest of the snapshot) was re-run per query and re-run 4~6 times per MTMV refresh, with no + * cross-query reuse. The three consumers ({@code buildMvccPartitionView} for the MVCC/MTMV partition view, + * {@code listPartitions} for {@code selectedPartitionNum}, {@code listPartitionNames} for SHOW PARTITIONS) all + * funnel through it, so they share a single scan per {@code (table, snapshot)}. + * + *

Snapshot-keyed, so always correct. A snapshot is immutable, so the derived partitions are a pure + * function of the key; a new commit yields a new snapshot id (a new key -> a live scan). Within the TTL the + * snapshot id itself is held stable by {@link IcebergLatestSnapshotCache}, which is what makes the key stable + * across queries and across the enumeration points of one MTMV refresh. + * + *

No credential gate (unlike {@link IcebergTableCache}): the cached value is pure metadata (partition + * names, values, transforms, timestamps, snapshot ids) and carries no {@code FileIO} / credential, so it is + * safe to share across users and is built unconditionally (only the TTL knob disables it). + * + *

Backed identically to {@link IcebergLatestSnapshotCache}: a contextual, access-TTL {@link MetaCacheEntry} + * with manual miss-load, so the scan runs OUTSIDE Caffeine's compute lock and its exception (e.g. the + * dropped-partition-source-column {@link org.apache.iceberg.exceptions.ValidationException} that + * {@code listPartitions} degrades on) propagates verbatim and a failed scan is not cached. TTL is + * {@code meta.cache.iceberg.table.ttl-second}; {@code <= 0} disables (read live). Lives on the long-lived + * per-catalog {@link IcebergConnector}; a REFRESH CATALOG rebuilds the connector and thus the cache. + */ +final class IcebergPartitionCache { + + /** Immutable composite key: a table's partition list is distinct per pinned snapshot id. */ + static final class Key { + final TableIdentifier id; + final long snapshotId; + + Key(TableIdentifier id, long snapshotId) { + this.id = id; + this.snapshotId = snapshotId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Key)) { + return false; + } + Key that = (Key) o; + return snapshotId == that.snapshotId && Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id, snapshotId); + } + } + + private final MetaCacheEntry> entry; + + IcebergPartitionCache(long ttlSeconds, int maxSize) { + // Mirror IcebergLatestSnapshotCache: translate the connector's "<= 0 disables" contract to CacheSpec's + // ttl == 0 (disabled) rather than passing a negative value through (which CacheSpec reads as "no + // expiration"). + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-partition", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always scan live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * Returns the cached raw partition list for {@code key} if present and unexpired, else runs {@code loader} + * (the live PARTITIONS scan), caches and returns it. Disabled cache -> {@code loader} every call. The + * loader runs OUTSIDE Caffeine's compute lock (single-flight per key) and its exception propagates unwrapped. + */ + List getOrLoad(Key key, Supplier> loader) { + return entry.get(key, ignored -> loader.get()); + } + + /** Drops every cached snapshot entry for one table so the next read scans live (REFRESH TABLE). */ + void invalidate(TableIdentifier id) { + entry.invalidateIf(key -> key.id.equals(id)); + } + + /** Drops every cached entry for one database (REFRESH DATABASE / DROP DATABASE); db match = namespace equality. */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(key -> key.id.namespace().equals(ns)); + } + + /** Drops all cached entries (REFRESH CATALOG). */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } + + /** Test-only: how many times the live loader (the PARTITIONS scan) actually ran — the metric gate. */ + long loadCountForTest() { + return entry.stats().getLoadSuccessCount(); + } +} 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 45b191180670c1..31767a03c4aef2 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 @@ -36,6 +36,7 @@ import org.apache.iceberg.Snapshot; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.types.Type; @@ -530,6 +531,16 @@ static List parsePartitionValuesFromJson(String partitionDataJson) { * handle), or {@code < 0} to enumerate at the table's current (latest) snapshot. */ static ConnectorMvccPartitionView buildMvccPartitionView(Table table, long pinnedSnapshotId) { + return buildMvccPartitionView(table, pinnedSnapshotId, null, null); + } + + /** + * Cache-aware overload (PERF-02): {@code id} + {@code cache} route the PARTITIONS scan through the + * per-catalog {@link IcebergPartitionCache} keyed by {@code (id, resolvedSnapshotId)}. {@code cache == null} + * reads live (offline tests / no-cache catalog). + */ + static ConnectorMvccPartitionView buildMvccPartitionView(Table table, long pinnedSnapshotId, + TableIdentifier id, IcebergPartitionCache cache) { if (!isValidRelatedTable(table)) { return ConnectorMvccPartitionView.unpartitioned(); } @@ -562,7 +573,7 @@ static ConnectorMvccPartitionView buildMvccPartitionView(Table table, long pinne // two rows rendering the same field=value name have byte-identical ranges, and feeding both to the merge // would make the name self-enclose and be dropped (0 partitions where master keeps 1). Map allByName = new LinkedHashMap<>(); - for (IcebergRawPartition raw : loadRawPartitions(table, snapshotId)) { + for (IcebergRawPartition raw : loadRawPartitions(id, table, snapshotId, cache)) { RangeBuild rb = buildRange(raw.name, raw.values.get(0), raw.transforms.get(0), sourceType, raw.lastUpdateTime, raw.lastSnapshotId); allByName.put(rb.name, rb); @@ -602,6 +613,12 @@ static ConnectorMvccPartitionView buildMvccPartitionView(Table table, long pinne * unpartitioned or empty table yields an empty list. Must run inside {@code context.executeAuthenticated}. */ static List listPartitionNames(Table table) { + return listPartitionNames(table, null, null); + } + + /** Cache-aware overload (PERF-02): see {@link #buildMvccPartitionView(Table, long, TableIdentifier, + * IcebergPartitionCache)}. Keyed by the table's CURRENT snapshot id. */ + static List listPartitionNames(Table table, TableIdentifier id, IcebergPartitionCache cache) { if (table.spec().isUnpartitioned()) { return Collections.emptyList(); } @@ -609,7 +626,7 @@ static List listPartitionNames(Table table) { if (current == null) { return Collections.emptyList(); } - List raws = loadRawPartitions(table, current.snapshotId()); + List raws = loadRawPartitions(id, table, current.snapshotId(), cache); List names = new ArrayList<>(raws.size()); for (IcebergRawPartition raw : raws) { names.add(raw.name); @@ -629,6 +646,12 @@ static List listPartitionNames(Table table) { * {@code context.executeAuthenticated}. */ static List listPartitions(Table table) { + return listPartitions(table, null, null); + } + + /** 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) { if (table.spec().isUnpartitioned()) { return Collections.emptyList(); } @@ -638,7 +661,7 @@ static List listPartitions(Table table) { } List raws; try { - raws = loadRawPartitions(table, current.snapshotId()); + raws = loadRawPartitions(id, table, current.snapshotId(), cache); } catch (ValidationException e) { if (!isDroppedPartitionSourceColumn(e)) { // A different iceberg validation error (not the dropped-partition-source-column case) — fail loud. @@ -706,7 +729,26 @@ static boolean isValidRelatedTable(Table table) { * {@code last_updated_at} (row 9) / {@code last_updated_snapshot_id} (row 10) are optional, so a missing * value (NPE on the typed getter) degrades to {@code 0} / {@code UNKNOWN_SNAPSHOT_ID}, exactly like master. */ - private static List loadRawPartitions(Table table, long snapshotId) { + /** + * The cross-query PARTITIONS-scan de-duplication seam (PERF-02): when {@code cache} is non-null the raw + * partition list is served from / populated into the per-catalog {@link IcebergPartitionCache} keyed by + * {@code (id, snapshotId)} — a snapshot is immutable, so the derived partitions are a pure function of that + * key and safe to reuse across queries (restoring the legacy IcebergExternalMetaCache partition-info cache). + * A {@code null} cache (offline unit tests / the no-cache catalog) reads live every call. The cached list is + * unmodifiable so a shared entry cannot be mutated by a concurrent reader; the loader's exception (e.g. the + * dropped-partition-source-column {@link ValidationException}) propagates verbatim so callers keep their own + * degradation, and a failed scan is not cached. + */ + private static List loadRawPartitions(TableIdentifier id, Table table, long snapshotId, + IcebergPartitionCache cache) { + if (cache == null) { + return loadRawPartitionsUncached(table, snapshotId); + } + return cache.getOrLoad(new IcebergPartitionCache.Key(id, snapshotId), + () -> Collections.unmodifiableList(loadRawPartitionsUncached(table, snapshotId))); + } + + private static List loadRawPartitionsUncached(Table table, long snapshotId) { Table partitionsTable = MetadataTableUtils.createMetadataTableInstance(table, MetadataTableType.PARTITIONS); List partitions = new ArrayList<>(); try (CloseableIterable tasks = partitionsTable.newScan().useSnapshot(snapshotId).planFiles()) { @@ -902,7 +944,7 @@ static long latestSnapshotId(String name, Map> mergeMap, } /** One PARTITIONS-metadata-table row reduced to what the MTMV partition view needs (port of IcebergPartition). */ - private static final class IcebergRawPartition { + static final class IcebergRawPartition { private final String name; // Partition-field SOURCE column names (lowercased), parallel to {@link #values}, so listPartitions can // build a value map keyed by the generic partition-column remote name (see IcebergConnectorMetadata diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergRewritableDeleteStash.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergRewritableDeleteStash.java deleted file mode 100644 index 3f153371b8bb95..00000000000000 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergRewritableDeleteStash.java +++ /dev/null @@ -1,140 +0,0 @@ -// 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.thrift.TIcebergDeleteFileDesc; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.function.LongSupplier; - -/** - * Per-catalog stash that carries the merge-on-read "rewritable delete" supply across the scan→write seam of a - * single row-level DML (DELETE / UPDATE / MERGE) on a format-version≥3 iceberg table. - * - *

Why a singleton stash. When a DML rewrites the deletes of a data file, the BE writes a NEW deletion - * vector that must OR-merge that file's still-live old deletes (old DVs + old position deletes) — otherwise the - * previously-deleted rows resurrect. The BE does not read iceberg metadata; the FE must hand it, per touched - * data file, the list of old non-equality delete files via {@code rewritable_delete_file_sets} - * ({@code TIcebergDeleteSink}/{@code TIcebergMergeSink}). The legacy fe-core path collected this from the - * {@code IcebergScanNode}'s own fields at sink-finalize time (plan-scoped, GC'd with the plan). In the plugin - * SPI the scan-plan provider and the write-plan provider are mutually-unaware, used-once objects; the only - * cross-scan→write survivor is the long-lived per-catalog {@link IcebergConnector}. So the scan provider stashes - * the supply here keyed by the statement's stable {@code queryId} ({@code ConnectorSession.getQueryId()} = - * {@code DebugUtil.printId(ctx.queryId())}, identical across the scan and write sessions of one statement), and - * the write provider retrieves it by the same key. - * - *

Key = RAW data-file path. The map is keyed on the un-normalized {@code dataFile.path()} string - * ({@link IcebergScanRange#getOriginalPath()}), because the BE matches a rewritable set to the file it is writing - * a DV for by exact string equality against that raw path (mirrors legacy - * {@code IcebergScanNode.deleteFilesByReferencedDataFile} keyed on {@code getOriginalPath()}). Keying on the - * scheme-normalized open path would silently miss every lookup → resurrection. - * - *

Eviction. The primary removal is the write provider's eager {@link #retrieveAndRemove} per statement - * (covers every DML write — DELETE/MERGE consume the supply, INSERT/OVERWRITE simply discard it). A statement - * that scans a v3-delete table but never writes (a plain {@code SELECT}, or a DML that errors before the write - * is planned) leaves a leaked entry that {@link #retrieveAndRemove} never reaches; a lazy TTL sweep (run when a - * new query is first seen) ages those out. Unlike a value cache, the sweep removes ONLY entries untouched for - * longer than the TTL — never a live entry (a statement's scan→write gap is milliseconds, far below the TTL), so - * a live supply is never dropped (which would itself resurrect rows). With a unique per-statement queryId a - * leaked entry is unreachable (no later statement reuses the key), so a leak is bounded memory, not a stale read. - */ -final class IcebergRewritableDeleteStash { - - // Leak backstop only: a statement's scan→write gap is milliseconds (both happen in one planning pass, before - // BE execution), so this TTL is orders of magnitude larger than any live entry's lifetime — it ages out only - // leaked plain-SELECT / aborted-DML entries. - private static final long DEFAULT_TTL_SECONDS = 300L; - - /** One in-flight statement's supply: rawDataFilePath -> its non-equality delete descs, plus a touch stamp. */ - private static final class Entry { - // Concurrent because, defensively, a single statement could plan more than one iceberg scan (MERGE scans - // both the target and the source table) — data-file paths are globally unique, so accumulating distinct - // keys (and idempotently overwriting a split data file's identical list) is always safe. - final Map> sets = new ConcurrentHashMap<>(); - volatile long lastTouchNanos; - - Entry(long nowNanos) { - this.lastTouchNanos = nowNanos; - } - } - - private final Map stash = new ConcurrentHashMap<>(); - private final long ttlNanos; - private final LongSupplier nanoClock; - - IcebergRewritableDeleteStash() { - this(DEFAULT_TTL_SECONDS, System::nanoTime); - } - - /** Visible for testing: injectable TTL + clock so sweep is deterministic without sleeping. */ - IcebergRewritableDeleteStash(long ttlSeconds, LongSupplier nanoClock) { - this.ttlNanos = TimeUnit.SECONDS.toNanos(Math.max(1L, ttlSeconds)); - this.nanoClock = nanoClock; - } - - /** - * Records, for {@code queryId}, the non-equality delete descs of one touched data file (keyed on its RAW - * path). No-op when the queryId is blank (a null/absent {@code ConnectContext} coerces the queryId to "", - * which would collide across concurrent statements — such a statement gets no supply, which is safe because a - * real row-level DML always carries a non-null query id) or when there is nothing to supply. Splits of one - * data file carry an identical delete list, so the per-path {@code put} is idempotent; two tables in one - * statement contribute distinct paths. - */ - void accumulate(String queryId, String rawDataFilePath, List deleteDescs) { - if (queryId == null || queryId.isEmpty() || rawDataFilePath == null - || deleteDescs == null || deleteDescs.isEmpty()) { - return; - } - long now = nanoClock.getAsLong(); - Entry entry = stash.get(queryId); - if (entry == null) { - // First range of a not-yet-seen query: opportunistically age out leaked entries. Done OUTSIDE the - // computeIfAbsent mapping function (ConcurrentHashMap forbids mutating other mappings from within it). - sweepExpired(now); - entry = stash.computeIfAbsent(queryId, k -> new Entry(now)); - } - entry.lastTouchNanos = now; - entry.sets.put(rawDataFilePath, deleteDescs); - } - - /** - * Returns and removes the supply map for {@code queryId} (the rewritable delete sets keyed by raw data-file - * path), or {@code null} when none was stashed / the queryId is blank. The write provider calls this once per - * statement; the remove is the primary eviction. - */ - Map> retrieveAndRemove(String queryId) { - if (queryId == null || queryId.isEmpty()) { - return null; - } - Entry entry = stash.remove(queryId); - return entry == null ? null : entry.sets; - } - - /** Drops entries untouched for longer than the TTL — leaked plain-SELECT / aborted-DML supplies only. */ - private void sweepExpired(long nowNanos) { - stash.entrySet().removeIf(e -> nowNanos - e.getValue().lastTouchNanos >= ttlNanos); - } - - /** Test-only: current number of in-flight stashed statements. */ - int size() { - return stash.size(); - } -} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java index 70cd39f5f5e076..b53465392350e5 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java @@ -37,6 +37,7 @@ import org.apache.doris.thrift.TIcebergFileDesc; import org.apache.doris.thrift.TTableFormatFileDesc; +import com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.BaseFileScanTask; import org.apache.iceberg.BaseTable; import org.apache.iceberg.BatchScan; @@ -65,6 +66,7 @@ import org.apache.iceberg.TableOperations; import org.apache.iceberg.TableProperties; import org.apache.iceberg.TableScan; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; @@ -94,6 +96,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -104,6 +107,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; +import java.util.function.UnaryOperator; /** * {@link ConnectorScanPlanProvider} for Iceberg tables, mirroring the paimon connector's @@ -216,12 +220,15 @@ public class IcebergScanPlanProvider implements ConnectorScanPlanProvider { // Nullable — null via the 2-/3-arg ctors (offline tests, default-disabled gate); when null the gate is // forced off and planScan uses the SDK splitFiles path. private final IcebergManifestCache manifestCache; - // commit-bridge supply (S4 part 2): owned by the long-lived IcebergConnector, shared with the write provider. - // A format-version>=3 DELETE/MERGE scan stashes its non-equality delete supply here keyed by queryId; the - // write provider retrieves it to fill rewritable_delete_file_sets. Nullable — null via the 2-/3-/4-arg ctors - // (offline tests), in which case stashing is skipped (the supply is exercised only on the post-cutover write - // path; pre-flip the provider never runs at all). - private final IcebergRewritableDeleteStash rewritableDeleteStash; + // PERF-01: cross-query RAW-table cache shared with the metadata layer, owned by the long-lived + // IcebergConnector and injected via getScanPlanProvider. Nullable — null via the offline-test ctors and + // when the connector's credential gate disables the cross-query layer; when null resolveTable still uses the + // per-statement scope and falls back to a direct remote load. + private final IcebergTableCache tableCache; + // PERF-03: cross-query inferred-file-format cache shared with the connector, owned by the long-lived + // IcebergConnector and injected via getScanPlanProvider. Nullable — null via the offline-test ctors; when null + // getScanNodeProperties resolves file_format_type live (matching pre-PERF-03 behaviour, node-memoized per query). + private final IcebergFormatCache formatCache; // FIX-SCAN-METRICS: per-query stash of the iceberg SDK scan diagnostics captured by the attached // IcebergScanProfileReporter during planScan, keyed by session queryId. fe-core drains it @@ -230,26 +237,37 @@ public class IcebergScanPlanProvider implements ConnectorScanPlanProvider { // system-table, which fe-core never drains), so the value list is appended single-threaded. private final ConcurrentHashMap> scanProfileStash = new ConcurrentHashMap<>(); + // Test-only gate for the PERF-11 per-file memo: how many times computePerFileInvariants actually ran across + // this provider's scans. A file split into k byte-slices must increment it ONCE (not k times), proving the + // per-slice recompute collapsed to per-file. Not reset per scan — a test uses a fresh provider. + @VisibleForTesting + int perFileInvariantComputeCount; + public IcebergScanPlanProvider(Map properties, IcebergCatalogOps catalogOps) { - this(properties, catalogOps, null, null, null); + this(properties, catalogOps, null, null); } public IcebergScanPlanProvider(Map properties, IcebergCatalogOps catalogOps, ConnectorContext context) { - this(properties, catalogOps, context, null, null); + this(properties, catalogOps, context, null); } public IcebergScanPlanProvider(Map properties, IcebergCatalogOps catalogOps, ConnectorContext context, IcebergManifestCache manifestCache) { - this(properties, catalogOps, context, manifestCache, null); + // Constant resolver: these ctors (offline tests + the pre-session connector paths) bind a single ops that + // ignores the session, so existing behaviour/tests are byte-identical. No cross-query cache (tableCache + // null) — the per-statement scope still dedups within a statement. + this(properties, session -> catalogOps, context, manifestCache, null); } - public IcebergScanPlanProvider(Map properties, IcebergCatalogOps catalogOps, - ConnectorContext context, IcebergManifestCache manifestCache, - IcebergRewritableDeleteStash rewritableDeleteStash) { - // Constant resolver: these ctors (offline tests + the pre-session connector paths) bind a single ops that - // ignores the session, so existing behaviour/tests are byte-identical. - this(properties, session -> catalogOps, context, manifestCache, rewritableDeleteStash); + /** + * Session-aware convenience ctor without a cross-query table cache (tableCache null); used by the offline + * session-routing tests. The per-statement scope still dedups within a statement. + */ + public IcebergScanPlanProvider(Map properties, + Function catalogOpsResolver, + ConnectorContext context, IcebergManifestCache manifestCache) { + this(properties, catalogOpsResolver, context, manifestCache, null); } /** @@ -260,13 +278,25 @@ public IcebergScanPlanProvider(Map properties, IcebergCatalogOps */ public IcebergScanPlanProvider(Map properties, Function catalogOpsResolver, - ConnectorContext context, IcebergManifestCache manifestCache, - IcebergRewritableDeleteStash rewritableDeleteStash) { + ConnectorContext context, IcebergManifestCache manifestCache, IcebergTableCache tableCache) { + this(properties, catalogOpsResolver, context, manifestCache, tableCache, null); + } + + /** + * Full ctor used by {@link IcebergConnector#getScanPlanProvider()}, adding the PERF-03 cross-query + * inferred-file-format cache ({@code formatCache}). The 5-arg ctor delegates here with a null format cache + * (offline tests + pre-cache paths resolve {@code file_format_type} live). + */ + public IcebergScanPlanProvider(Map properties, + Function catalogOpsResolver, + ConnectorContext context, IcebergManifestCache manifestCache, IcebergTableCache tableCache, + IcebergFormatCache formatCache) { this.properties = properties; this.catalogOpsResolver = catalogOpsResolver; this.context = context; this.manifestCache = manifestCache; - this.rewritableDeleteStash = rewritableDeleteStash; + this.tableCache = tableCache; + this.formatCache = formatCache; } /** @@ -457,12 +487,36 @@ public ConnectorSplitSource streamSplits(ConnectorSession session, ConnectorTabl boolean partitioned = table.spec().isPartitioned(); Map vendedToken = context != null ? extractVendedToken(table, restVendedCredentialsEnabled()) : Collections.emptyMap(); + UnaryOperator uriNormalizer = newUriNormalizer(vendedToken); long fileSplitSize = sessionLong(session, FILE_SPLIT_SIZE, 0L); long sliceSize = fileSplitSize > 0 ? fileSplitSize : sessionLong(session, MAX_FILE_SPLIT_SIZE, DEFAULT_MAX_FILE_SPLIT_SIZE); - CloseableIterable tasks = TableScanUtil.splitFiles(scan.planFiles(), sliceSize); + CloseableIterable tasks = streamingFileScanTasks(scan, session, table, filter, sliceSize); return new IcebergStreamingSplitSource(tasks, table, formatVersion, partitioned, - orderedPartitionKeys, zone, vendedToken, sliceSize, iceHandle.getRewriteFileScope()); + orderedPartitionKeys, zone, uriNormalizer, sliceSize, iceHandle.getRewriteFileScope()); + } + + /** + * The streaming source's whole-file enumeration, byte-offset-split at {@code sliceSize}. PERF-04 (C17): when + * the manifest cache is enabled, read manifests THROUGH THE CACHE via the lazy {@link #cacheBackedFileScanTasks} + * (no-stats overload — Phase 2 iterates on the engine pump thread, see there) so a big streaming scan finally + * hits the cache while staying lazy (OOM-safe). An eager Phase-1 cache failure records it and falls back to the + * SDK {@code planFiles()} path (mirrors {@link #planFileScanTask}); a later lazy failure surfaces through the + * streaming source's {@code hasNext}. Cache disabled -> the SDK path, byte-unchanged. + */ + private CloseableIterable streamingFileScanTasks(TableScan scan, ConnectorSession session, + Table table, Optional filter, long sliceSize) { + if (isManifestCacheEnabled()) { + try { + return TableScanUtil.splitFiles( + cacheBackedFileScanTasks(scan, session, table, filter, null), sliceSize); + } catch (Exception e) { + LOG.warn("Iceberg streaming plan with manifest cache failed, falling back to SDK scan: {}", + e.getMessage(), e); + manifestCache.recordFailure(session.getQueryId()); + } + } + return TableScanUtil.splitFiles(scan.planFiles(), sliceSize); } /** @@ -479,7 +533,7 @@ private final class IcebergStreamingSplitSource implements ConnectorSplitSource private final boolean partitioned; private final List orderedPartitionKeys; private final ZoneId zone; - private final Map vendedToken; + private final UnaryOperator uriNormalizer; private final long sliceSize; private final Set rewriteScope; // Lazily opened on first hasNext() so the ctor never throws — iceberg's ParallelIterable submits @@ -490,17 +544,20 @@ private final class IcebergStreamingSplitSource implements ConnectorSplitSource private CloseableIterator iterator; // Look-ahead buffer so hasNext() can skip data files filtered out by the rewrite scope. private IcebergScanRange buffered; + // Per-file invariant cache (PERF-11): per split-source = per scan; the pump is single-threaded, so the + // 1-entry cache stays O(1) memory (never accumulates), preserving the streaming path's OOM safety. + private final PerFileScratch scratch = new PerFileScratch(); IcebergStreamingSplitSource(CloseableIterable tasks, Table table, int formatVersion, boolean partitioned, List orderedPartitionKeys, ZoneId zone, - Map vendedToken, long sliceSize, Set rewriteScope) { + UnaryOperator uriNormalizer, long sliceSize, Set rewriteScope) { this.tasks = tasks; this.table = table; this.formatVersion = formatVersion; this.partitioned = partitioned; this.orderedPartitionKeys = orderedPartitionKeys; this.zone = zone; - this.vendedToken = vendedToken; + this.uriNormalizer = uriNormalizer; this.sliceSize = sliceSize; this.rewriteScope = rewriteScope; } @@ -515,7 +572,7 @@ public boolean hasNext() { } while (iterator.hasNext()) { IcebergScanRange range = buildRangeForTask(iterator.next(), table, formatVersion, partitioned, - orderedPartitionKeys, zone, vendedToken, sliceSize, rewriteScope, false, null); + orderedPartitionKeys, zone, uriNormalizer, sliceSize, rewriteScope, null, scratch); if (range != null) { buffered = range; return true; @@ -585,6 +642,9 @@ private List planScanInternal( // BE-credential overlay is emitted separately by getScanNodeProperties. Map vendedToken = context != null ? extractVendedToken(table, restVendedCredentialsEnabled()) : Collections.emptyMap(); + // Derive the vended storage config ONCE per scan (the token is scan-invariant) and reuse it for every + // per-file path normalization below, instead of rebuilding it per data/delete file (C3). + UnaryOperator uriNormalizer = newUriNormalizer(vendedToken); // COUNT(*) pushdown (T05): when the count is servable from the snapshot summary, collapse the scan to // a single whole-file range carrying the full count (mirrors paimon's collapse + legacy's <=10000 @@ -595,7 +655,7 @@ private List planScanInternal( long realCount = getCountFromSnapshot(scan, session); if (realCount >= 0) { return planCountPushdown(table, scan, realCount, formatVersion, partitioned, - orderedPartitionKeys, zone, vendedToken); + orderedPartitionKeys, zone, uriNormalizer, session, filter); } } @@ -603,15 +663,15 @@ private List planScanInternal( // and emit one BE-ready IcebergScanRange per task, populating the typed iceberg carriers — incl. the // merge-on-read delete files (T04) — mirroring legacy IcebergScanNode.createIcebergSplit. The field-id // history dict (T06, scan-level), MVCC pin, and vended credentials (T09) land later. - // commit-bridge supply (S4 part 2): for a format-version>=3 scan, stash each data file's non-equality - // delete supply (old DVs + old position deletes) keyed by the statement queryId, so a DELETE/MERGE write - // on the same statement can fill rewritable_delete_file_sets and the BE OR-merges those old deletes into - // the new deletion vector — a missing supply silently resurrects previously-deleted rows. queryId is read - // once (stable across this statement's scan and write sessions). Skipped pre-v3 and when the stash is - // absent (offline tests / pre-cutover the provider never runs); a non-DML scan just leaves a leaked entry - // the stash ages out, and accumulate() itself no-ops a blank queryId or an empty (no non-eq delete) list. - boolean stashRewritableDeletes = rewritableDeleteStash != null && formatVersion >= 3; - String stashQueryId = stashRewritableDeletes ? session.getQueryId() : null; + // commit-bridge supply (S4 part 2): for a format-version>=3 scan, accumulate each data file's non-equality + // delete supply (old DVs + old position deletes) into the per-statement scope, so a DELETE/MERGE write on + // the same statement can fill rewritable_delete_file_sets and the BE OR-merges those old deletes into the + // new deletion vector — a missing supply silently resurrects previously-deleted rows. The scope is keyed by + // catalog id + queryId (shared across this statement's scan and write, isolated per catalog for a + // cross-catalog MERGE). Skipped pre-v3; a non-DML scan just leaves an entry GC'd with the statement, and an + // absent scope (offline) yields a throwaway map that the write seam guards against (fail loud on v3 DML). + Map> rewritableDeleteSupply = formatVersion >= 3 + ? IcebergStatementScope.rewritableDeleteSupply(session) : null; // WS-REWRITE R2 per-group scope: when the handle carries a rewrite file scope (the engine // rewrite_data_files driver sets it before each group's INSERT-SELECT), keep ONLY the data files in @@ -623,14 +683,18 @@ private List planScanInternal( // (buildRange re-attaches task.deletes()), so scoping never drops a delete binding. Set rewriteScope = iceHandle.getRewriteFileScope(); + // Per-file invariant cache (PERF-11): ONE instance per scan, never shared across scans. Reused across + // a data file's consecutive byte-slices so partition JSON / identity map / delete carriers are computed + // once per file, not per slice. The streaming source below holds its own. + PerFileScratch scratch = new PerFileScratch(); List ranges = new ArrayList<>(); try (SplitPlan plan = planFileScanTask(scan, session, table, filter)) { for (FileScanTask task : plan.tasks) { // Shared per-task mapping (rewrite-scope skip + M-2 weight denominator + v3 stash side-effect), // identical to the streaming path's IcebergStreamingSplitSource so both produce the same ranges. IcebergScanRange range = buildRangeForTask(task, table, formatVersion, partitioned, - orderedPartitionKeys, zone, vendedToken, plan.targetSplitSize, rewriteScope, - stashRewritableDeletes, stashQueryId); + orderedPartitionKeys, zone, uriNormalizer, plan.targetSplitSize, rewriteScope, + rewritableDeleteSupply, scratch); if (range != null) { ranges.add(range); } @@ -681,26 +745,35 @@ private static String rootCauseMessage(Throwable t) { /** * Map one {@link FileScanTask} to its BE-ready {@link IcebergScanRange}, applying the rewrite-scope filter * (returns {@code null} to skip a data file outside the scope) and the v3 commit-bridge rewritable-delete - * stash side-effect. Shared by the synchronous {@link #planScanInternal} loop and the streaming + * accumulation. Shared by the synchronous {@link #planScanInternal} loop and the streaming * {@code IcebergStreamingSplitSource} so both paths produce byte-identical ranges and never drop a - * side-effect. The streaming path passes {@code stashRewritableDeletes=false} (v3 is gated onto the eager - * path — see {@link #streamingSplitEstimate}), so the stash is inert there. + * side-effect. The streaming path passes {@code rewritableDeleteSupply=null} (v3 is gated onto the eager + * path — see {@link #streamingSplitEstimate}), so the accumulation is inert there. */ private IcebergScanRange buildRangeForTask(FileScanTask task, Table table, int formatVersion, boolean partitioned, List orderedPartitionKeys, ZoneId zone, - Map vendedToken, long targetSplitSize, Set rewriteScope, - boolean stashRewritableDeletes, String stashQueryId) { + UnaryOperator uriNormalizer, long targetSplitSize, Set rewriteScope, + Map> rewritableDeleteSupply, PerFileScratch scratch) { DataFile dataFile = task.file(); if (rewriteScope != null && !rewriteScope.contains(dataFile.path().toString())) { return null; } + // First byte-slice of a new data file? (scratch still holds the previous file until buildRange refreshes + // it below — so capture this BEFORE the buildRange call.) The v3 rewritable-delete supply is identical + // for every slice of a file, so record it exactly ONCE per file — on the file's first slice, keyed to + // the new file, so the LAST file in the stream is never dropped. + boolean firstSliceOfFile = scratch.file != dataFile; // targetSplitSize is the scan-level weight denominator (M-2): each data-file range carries a // size-proportional BE scheduling weight (selfSplitWeight computed inside buildRange). IcebergScanRange range = buildRange(table, dataFile, task, formatVersion, partitioned, - orderedPartitionKeys, zone, vendedToken, -1, targetSplitSize); - if (stashRewritableDeletes) { - rewritableDeleteStash.accumulate(stashQueryId, range.getOriginalPath(), - range.rewritableDeleteDescs()); + orderedPartitionKeys, zone, uriNormalizer, -1, targetSplitSize, scratch); + if (rewritableDeleteSupply != null && firstSliceOfFile) { + // Record this data file's non-equality delete supply keyed on its RAW path (the exact string the BE + // matches a rewritable set against). An empty list (no old non-eq deletes) contributes nothing. + List descs = range.rewritableDeleteDescs(); + if (range.getOriginalPath() != null && descs != null && !descs.isEmpty()) { + rewritableDeleteSupply.put(range.getOriginalPath(), descs); + } } return range; } @@ -873,12 +946,13 @@ private List doPlanPositionDeletesSystemTableScan(IcebergTab ZoneId zone = resolveSessionZone(session); Map vendedToken = context != null ? extractVendedToken(metadataTable, restVendedCredentialsEnabled()) : Collections.emptyMap(); + UnaryOperator uriNormalizer = newUriNormalizer(vendedToken); List ranges = new ArrayList<>(); for (PositionDeletesScanTask task : tasks) { for (PositionDeletesScanTask splitTask : splitPositionDeleteScanTask(task, targetSplitSize)) { ranges.add(buildPositionDeleteRange(splitTask, metadataTable, outputPartitionFields, - enableMappingVarbinary, zone, vendedToken)); + enableMappingVarbinary, zone, uriNormalizer)); } } LOG.debug("Iceberg planScan produced {} position_deletes splits for {}.{}", ranges.size(), @@ -898,11 +972,11 @@ private static Iterable splitPositionDeleteScanTask(Pos */ private IcebergScanRange buildPositionDeleteRange(PositionDeletesScanTask task, Table metadataTable, List outputPartitionFields, boolean enableMappingVarbinary, ZoneId zone, - Map vendedToken) { + UnaryOperator uriNormalizer) { DeleteFile deleteFile = task.file(); String originalPath = deleteFile.path().toString(); IcebergScanRange.Builder builder = new IcebergScanRange.Builder() - .path(normalizeUri(originalPath, vendedToken)) + .path(uriNormalizer.apply(originalPath)) .start(task.start()) .length(task.length()) .fileSize(deleteFile.fileSizeInBytes()) @@ -1090,13 +1164,13 @@ private static Schema pinnedSchema(Table table, IcebergTableHandle handle) { */ private List planCountPushdown(Table table, TableScan scan, long realCount, int formatVersion, boolean partitioned, List orderedPartitionKeys, ZoneId zone, - Map vendedToken) { - try (CloseableIterable tasks = scan.planFiles()) { + UnaryOperator uriNormalizer, ConnectorSession session, Optional filter) { + try (CloseableIterable tasks = countPushdownFileScanTasks(scan, session, table, filter)) { for (FileScanTask task : tasks) { // targetSplitSize = -1: the count-pushdown collapse emits a single range, so its scheduling // weight is irrelevant → PluginDrivenSplit keeps SplitWeight.standard(). return Collections.singletonList(buildRange(table, task.file(), task, formatVersion, - partitioned, orderedPartitionKeys, zone, vendedToken, realCount, -1)); + partitioned, orderedPartitionKeys, zone, uriNormalizer, realCount, -1, null)); } } catch (IOException e) { throw new RuntimeException("Failed to plan iceberg count-pushdown file, error message is:" @@ -1105,16 +1179,117 @@ private List planCountPushdown(Table table, TableScan scan, return Collections.emptyList(); } + /** + * The COUNT(*)-pushdown placeholder enumeration: only the FIRST surviving file is consumed (BE serves the + * count from {@code table_level_row_count} and never reads the file). PERF-04 (C18): when the manifest cache is + * enabled, read through the lazy {@link #cacheBackedFileScanTasks} (stats overload — this runs on the single + * planning thread) so the manifest reads are cache hits and, being lazy, stop at the first file's manifest + * instead of the SDK {@code planFiles()}'s {@code ParallelIterable} eagerly submitting every manifest reader. + * An eager cache failure falls back to the SDK path (mirrors {@link #planFileScanTask}). The first surviving + * (pruned) file may differ from the SDK path's first file (its {@code ParallelIterable} order is + * non-deterministic), but the count is identical (from the snapshot summary) and BE ignores the file. Cache + * disabled -> the SDK path, byte-unchanged. + */ + private CloseableIterable countPushdownFileScanTasks(TableScan scan, ConnectorSession session, + Table table, Optional filter) { + if (isManifestCacheEnabled()) { + try { + return cacheBackedFileScanTasks(scan, session, table, filter, session.getQueryId()); + } catch (Exception e) { + LOG.warn("Iceberg count-pushdown plan with manifest cache failed, falling back to SDK scan: {}", + e.getMessage(), e); + manifestCache.recordFailure(session.getQueryId()); + } + } + return scan.planFiles(); + } + + /** + * Per-file scratch for {@link #buildRange}: the values identical for every byte-slice of one data file + * ({@code TableScanUtil.splitFiles} cuts a file into k slices whose {@code FileScanTask}s all return the + * SAME {@code DataFile} instance from {@code file()}, and emits them consecutively). Computed once on a + * file change and reused across the file's slices, collapsing the per-slice partition-JSON / identity-map / + * delete-carrier recompute to per-file (PERF-11 / C12); the k ranges then share the same immutable + * {@code partitionValues} / {@code deleteCarriers} instances (C15a). The eager loop and the streaming + * source each own ONE instance, never shared across scans. {@code file == null} = fresh / unused. + */ + private static final class PerFileScratch { + private DataFile file; + private Integer partitionSpecId; + private String partitionDataJson; + private Map partitionValues = Collections.emptyMap(); + private List deleteCarriers = Collections.emptyList(); + private String fileFormat; + private Long firstRowId; + private Long lastUpdatedSequenceNumber; + private String rawDataPath; + private String normalizedPath; + } + /** * Build the BE-ready {@link IcebergScanRange} for one {@link FileScanTask}, mirroring legacy * {@code IcebergScanNode.createIcebergSplit} + {@code setIcebergParams}: the file path/offset/size, the * per-file format (native parquet/orc), the table format version, the v3 row-lineage fields, and — for a * partitioned table — the partition spec-id, the all-fields {@code partition_data_json}, and the ordered * identity {@code partitionValues} that become columns-from-path. + * + *

The per-file-invariant work is memoized through {@code scratch} (keyed by the shared {@code DataFile} + * instance) and reused across the file's byte-slices; only {@code start} / {@code length} / the + * size-proportional {@code selfSplitWeight} are per-slice. A {@code null} scratch (the single + * count-pushdown range) computes without caching. Byte-identical to the former per-slice recompute.

*/ private IcebergScanRange buildRange(Table table, DataFile dataFile, FileScanTask task, int formatVersion, boolean partitioned, List orderedPartitionKeys, ZoneId zone, - Map vendedToken, long pushDownRowCount, long targetSplitSize) { + UnaryOperator uriNormalizer, long pushDownRowCount, long targetSplitSize, + PerFileScratch scratch) { + PerFileScratch file = (scratch != null && scratch.file == dataFile) + ? scratch + : computePerFileInvariants(table, dataFile, task, formatVersion, partitioned, + orderedPartitionKeys, zone, uriNormalizer, scratch); + // M-2 size-proportional weight numerator = this split's byte length + the byte size of every + // merge-on-read delete file applying to it, mirroring legacy IcebergSplit.selfSplitWeight (ctor sets + // = length; setDeleteFileFilters adds Σ delete fileSizeInBytes). The delete-size sum is file-invariant + // but the task.length() term is per byte-slice, so the whole weight stays per-slice (NEVER memoized). + // The denominator (targetSplitSize) is passed in; for the single count-pushdown range it is -1 → + // PluginDrivenSplit keeps SplitWeight.standard() (one range, weight irrelevant). + long selfSplitWeight = task.length(); + if (task.deletes() != null) { + for (DeleteFile delete : task.deletes()) { + selfSplitWeight += delete.fileSizeInBytes(); + } + } + return new IcebergScanRange.Builder() + .path(file.normalizedPath) + .originalPath(file.rawDataPath) + .start(task.start()) + .length(task.length()) + .fileSize(dataFile.fileSizeInBytes()) + .fileFormat(file.fileFormat) + .formatVersion(formatVersion) + .partitionSpecId(file.partitionSpecId) + .partitionDataJson(file.partitionDataJson) + .firstRowId(file.firstRowId) + .lastUpdatedSequenceNumber(file.lastUpdatedSequenceNumber) + .partitionValues(file.partitionValues) + .deleteFiles(file.deleteCarriers) + .pushDownRowCount(pushDownRowCount) + .selfSplitWeight(selfSplitWeight) + .targetSplitSize(targetSplitSize) + .build(); + } + + /** + * Compute {@link #buildRange}'s per-file invariants and, when {@code scratch} is non-null, store them into + * it so the file's remaining byte-slices reuse them. Uses {@code task.deletes()} (identical across a + * file's slices) for the delete carriers. Mirrors the former inline per-slice computation exactly — same + * partition-JSON / identity-map ordering, same fail-loud on a non-parquet/orc file, same v3 row-lineage — + * so the memoized range is byte-identical to the per-slice recompute. The scratch fields are assigned only + * after the fail-loud check, so a rejected file never leaves a half-populated scratch. + */ + private PerFileScratch computePerFileInvariants(Table table, DataFile dataFile, FileScanTask task, + int formatVersion, boolean partitioned, List orderedPartitionKeys, ZoneId zone, + UnaryOperator uriNormalizer, PerFileScratch scratch) { + perFileInvariantComputeCount++; Integer partitionSpecId = null; String partitionDataJson = null; Map partitionValues = Collections.emptyMap(); @@ -1155,40 +1330,22 @@ private IcebergScanRange buildRange(Table table, DataFile dataFile, FileScanTask dataFile.fileSequenceNumber() != null && dataFile.firstRowId() != null ? dataFile.fileSequenceNumber() : -1L; } - // M-2 size-proportional weight numerator = this split's byte length + the byte size of every - // merge-on-read delete file applying to it, mirroring legacy IcebergSplit.selfSplitWeight (ctor sets - // = length; setDeleteFileFilters adds Σ delete fileSizeInBytes). task.deletes() is a cached list (no - // extra I/O; buildDeleteFiles reads the same one). The denominator (targetSplitSize) is passed in; for - // the single count-pushdown range it is -1 → PluginDrivenSplit keeps SplitWeight.standard() (one range, - // weight irrelevant), matching the normal-data-only weighting. - long selfSplitWeight = task.length(); - if (task.deletes() != null) { - for (DeleteFile delete : task.deletes()) { - selfSplitWeight += delete.fileSizeInBytes(); - } - } // The range path BE opens is scheme-normalized (legacy createIcebergSplit:852 normalizes via the // 2-arg LocationPath.of(path, storagePropertiesMap)); original_file_path stays raw so BE can match // position-delete entries against the raw iceberg path (legacy setOriginalFilePath:304). String rawDataPath = dataFile.path().toString(); - return new IcebergScanRange.Builder() - .path(normalizeUri(rawDataPath, vendedToken)) - .originalPath(rawDataPath) - .start(task.start()) - .length(task.length()) - .fileSize(dataFile.fileSizeInBytes()) - .fileFormat(fileFormat) - .formatVersion(formatVersion) - .partitionSpecId(partitionSpecId) - .partitionDataJson(partitionDataJson) - .firstRowId(firstRowId) - .lastUpdatedSequenceNumber(lastUpdatedSequenceNumber) - .partitionValues(partitionValues) - .deleteFiles(buildDeleteFiles(task, vendedToken)) - .pushDownRowCount(pushDownRowCount) - .selfSplitWeight(selfSplitWeight) - .targetSplitSize(targetSplitSize) - .build(); + PerFileScratch file = scratch != null ? scratch : new PerFileScratch(); + file.file = dataFile; + file.partitionSpecId = partitionSpecId; + file.partitionDataJson = partitionDataJson; + file.partitionValues = partitionValues; + file.fileFormat = fileFormat; + file.firstRowId = firstRowId; + file.lastUpdatedSequenceNumber = lastUpdatedSequenceNumber; + file.rawDataPath = rawDataPath; + file.normalizedPath = uriNormalizer.apply(rawDataPath); + file.deleteCarriers = buildDeleteFiles(task, uriNormalizer); + return file; } /** @@ -1196,14 +1353,15 @@ private IcebergScanRange buildRange(Table table, DataFile dataFile, FileScanTask * mirroring legacy {@code IcebergScanNode.getDeleteFileFilters} + {@code IcebergDeleteFileFilter}. Empty * for v1 / no-delete files (v1 has no delete files, so {@code task.deletes()} is always empty there). */ - private List buildDeleteFiles(FileScanTask task, Map vendedToken) { + private List buildDeleteFiles(FileScanTask task, + UnaryOperator uriNormalizer) { List deletes = task.deletes(); if (deletes == null || deletes.isEmpty()) { return Collections.emptyList(); } List result = new ArrayList<>(deletes.size()); for (DeleteFile delete : deletes) { - result.add(convertDelete(delete, vendedToken)); + result.add(convertDelete(delete, uriNormalizer)); } return result; } @@ -1218,13 +1376,13 @@ private List buildDeleteFiles(FileScanTask task, Ma *
  • {@code EQUALITY_DELETES} → an equality delete (content 2) with the delete-file's equality * field-ids (read straight from delete metadata — correct independent of the T06 data dictionary).
  • * - * The delete path is normalized through the engine seam (legacy - * {@code LocationPath.of(path,config).toStorageLocation()}), threading the per-table vended token (empty - * for non-REST) so a REST object-store deletion path normalizes via the vended map (T09). Package-private - * for direct unit testing. + * The delete path is normalized through the scan-scoped {@code uriNormalizer} (legacy + * {@code LocationPath.of(path,config).toStorageLocation()}), which bakes in the per-table vended token + * (empty for non-REST) so a REST object-store deletion path normalizes via the vended map (T09). + * Package-private for direct unit testing. */ - IcebergScanRange.DeleteFile convertDelete(DeleteFile delete, Map vendedToken) { - String path = normalizeUri(delete.path().toString(), vendedToken); + IcebergScanRange.DeleteFile convertDelete(DeleteFile delete, UnaryOperator uriNormalizer) { + String path = uriNormalizer.apply(delete.path().toString()); FileContent content = delete.content(); if (content == FileContent.POSITION_DELETES) { Long lowerBound = readPositionBound(delete.lowerBounds()); @@ -1281,20 +1439,22 @@ private static TFileFormatType deleteFileFormat(FileFormat format) { } /** - * Normalize a raw iceberg storage path (the data file BE opens, or a delete file) to BE's canonical - * scheme via the engine seam (legacy goes through {@code LocationPath.of(path, storagePropertiesMap) - * .toStorageLocation()}; the connector cannot import fe-core's {@code LocationPath}). BE's - * scheme-dispatched S3 factory only opens {@code s3://}, so an un-normalized {@code oss://}/{@code cos://} - * /{@code obs://}/{@code s3a://} path fails the native read (data file) or silently drops the deletes + * Build the scan-scoped URI normalizer once (where the per-table vended token is extracted) and thread it + * through the per-file range builders, instead of re-deriving the vended storage config per data/delete + * file. Each application normalizes a raw iceberg storage path (the data file BE opens, or a delete file) + * to BE's canonical scheme via the engine seam (legacy goes through {@code LocationPath.of(path, + * storagePropertiesMap).toStorageLocation()}; the connector cannot import fe-core's {@code LocationPath}). + * BE's scheme-dispatched S3 factory only opens {@code s3://}, so an un-normalized {@code oss://}/{@code + * cos://}/{@code obs://}/{@code s3a://} path fails the native read (data file) or silently drops the deletes * (merge-on-read wrong rows). Mirrors paimon's {@code normalizeUri} (FIX-URI-NORMALIZE), which normalizes * both the data-file and deletion-vector paths. The {@code vendedToken} (empty for non-REST / no context) - * is the per-table vended credential map, routed into normalization so a REST object-store path normalizes - * via the vended map (T09); when empty the 2-arg seam folds to the catalog's static storage map, byte- - * equivalent to legacy for non-vended catalogs. A {@code null} context (offline unit tests) preserves the - * raw path (paimon parity). + * is the per-table vended credential map, baked into the normalizer so a REST object-store path normalizes + * via the vended map (T09); when empty the seam folds to the catalog's static storage map, byte-equivalent + * to legacy for non-vended catalogs. A {@code null} context (offline unit tests) yields an identity + * normalizer that preserves the raw path (paimon parity). */ - private String normalizeUri(String rawPath, Map vendedToken) { - return context != null ? context.normalizeStorageUri(rawPath, vendedToken) : rawPath; + UnaryOperator newUriNormalizer(Map vendedToken) { + return context != null ? context.newStorageUriNormalizer(vendedToken) : UnaryOperator.identity(); } /** @@ -1381,8 +1541,14 @@ public Map getScanNodeProperties( // V1-only reader bug. Emit the table's real data format (legacy IcebergScanNode.getFileFormatType // parity, same IcebergUtils.getFileFormat resolution, which throws on a non-parquet/orc table); the // per-file format still travels per range, since one table may mix parquet and orc data files. + // PERF-03: the non-system format resolution falls back to an unfiltered whole-table planFiles() when the + // table sets neither write-format nor write.format.default; memoize that inference per (table, snapshot) + // across queries via formatCache (pure metadata, no credential gate). Null cache (offline) resolves live. props.put("file_format_type", - systemTable ? "jni" : IcebergWriterHelper.getFileFormat(table).name().toLowerCase(Locale.ROOT)); + systemTable ? "jni" + : IcebergWriterHelper.getFileFormat(table, + TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), formatCache) + .name().toLowerCase(Locale.ROOT)); // [D-065] System (metadata) tables ($snapshots/$files/...) read via the JNI serialized-split path // (planSystemTableScan): the metadata-table schema travels INSIDE the serialized FileScanTask, so BE // needs neither the base-table path_partition_keys (a metadata table is not base-spec partitioned -> @@ -1768,24 +1934,62 @@ private SplitPlan planFileScanTask(TableScan scan, ConnectorSession session, Tab } /** - * Manifest-level planning that consumes {@link IcebergManifestCache}, ported faithfully from legacy - * {@code IcebergScanNode.planFileScanTaskWithManifestCache}. It reconstructs iceberg's own planning: - * partition-prune manifests with a {@link ManifestEvaluator}, read each surviving manifest's data/delete - * files THROUGH THE CACHE, then per data file apply the {@link InclusiveMetricsEvaluator} (file-stats - * pruning) + {@link ResidualEvaluator} (partition residual) and attach its deletes via a - * {@link DeleteFileIndex}. The resulting {@link FileScanTask}s are byte-offset-split exactly like - * {@link #splitFiles}, so the downstream {@code buildRange} (T03-T07) is unchanged. The predicate / - * metrics / schema use the table's CURRENT schema (legacy parity). + * Synchronous (below-batch-threshold) manifest-cache planning: materialize the shared lazy + * {@link #cacheBackedFileScanTasks} enumeration into a list, because {@link #determineTargetFileSplitSize} + * (the per-table heuristic that gives small tables good BE parallelism) needs the whole task list. Byte + * identical to the pre-PERF-04 body; the streaming ({@link #streamSplits}) and COUNT(*) + * ({@link #planCountPushdown}) paths consume the same enumeration LAZILY instead (bounded FE heap). Uses the + * stats-tallying cache overload — this path runs on the single planning thread. The predicate / metrics / + * schema use the table's CURRENT schema (legacy parity). */ private SplitPlan planFileScanTaskWithManifestCache(TableScan scan, ConnectorSession session, Table table, Optional filter) throws IOException { + // Null-safe queryId (offline tests pass a null session): a null id selects the no-stats cache overload, + // matching the pre-PERF-04 body, which read scan.snapshot() and returned early for an empty table BEFORE + // ever calling session.getQueryId(). + String statsQueryId = session != null ? session.getQueryId() : null; + List tasks = new ArrayList<>(); + try (CloseableIterable whole = + cacheBackedFileScanTasks(scan, session, table, filter, statsQueryId)) { + for (FileScanTask task : whole) { + tasks.add(task); + } + } + long targetSplitSize = determineTargetFileSplitSize(tasks, session); + return new SplitPlan( + TableScanUtil.splitFiles(CloseableIterable.withNoopClose(tasks), targetSplitSize), targetSplitSize); + } + + /** + * PERF-04: the lazy, cache-backed {@link FileScanTask} enumeration shared by the synchronous + * ({@link #planFileScanTaskWithManifestCache}), streaming ({@link #streamSplits}), and COUNT(*) + * ({@link #planCountPushdown}) paths whenever {@link #isManifestCacheEnabled()}. Ported faithfully from legacy + * {@code IcebergScanNode.planFileScanTaskWithManifestCache}: partition-prune manifests with a + * {@link ManifestEvaluator}, read each surviving manifest's data/delete files THROUGH THE CACHE, then per data + * file apply the {@link InclusiveMetricsEvaluator} (file-stats prune) + {@link ResidualEvaluator} (partition + * residual) and attach its deletes via a {@link DeleteFileIndex}. Produces WHOLE-FILE + * {@link BaseFileScanTask}s (callers byte-offset-split via {@link TableScanUtil#splitFiles}). + * + *

    Phase 1 is eager (delete-manifest reads + {@link DeleteFileIndex} build): a data file's deletes + * need the full index before any data task can be produced, and running it here (on the caller's thread) lets + * the streaming/count callers catch a cache failure and fall back to the SDK path. Bounded like the SDK + * {@code planFiles()} (which also reads all delete manifests up front); delete manifests are far fewer than + * data manifests. Phase 2 is lazy: the returned iterable's iterator flat-maps the matching data + * manifests, yielding one surviving task at a time WITHOUT materializing the list, so a million-file streaming + * scan keeps FE heap bounded (peak = the delete index + one manifest's files + the split queue). + * + *

    {@code statsQueryId} is nullable: a non-null id tallies cache hits/misses under that query (the + * single-threaded synchronous + COUNT paths); {@code null} selects the no-stats overload for the STREAMING + * path, whose Phase 2 runs on the engine pump thread while Phase 1 ran on the calling thread — tallying the + * per-query {@code ScanStats} counters from two threads would race (the cache documents them as + * single-thread-per-query), and streaming reports no manifest-cache stats today anyway. + */ + private CloseableIterable cacheBackedFileScanTasks(TableScan scan, + ConnectorSession session, Table table, Optional filter, String statsQueryId) { Snapshot snapshot = scan.snapshot(); if (snapshot == null) { - return new SplitPlan(CloseableIterable.withNoopClose(Collections.emptyList()), -1); + return CloseableIterable.withNoopClose(Collections.emptyList()); } - // Stable per-statement key so VERBOSE EXPLAIN (rendered on a different, transient provider instance) can - // report THIS scan's manifest-cache hits/misses via the shared per-catalog cache. - String queryId = session.getQueryId(); Expression filterExpr = combineFilter(filter, table, session); Map specsById = table.specs(); boolean caseSensitive = true; @@ -1795,8 +1999,9 @@ private SplitPlan planFileScanTaskWithManifestCache(TableScan scan, ResidualEvaluator.of(spec, filterExpr, caseSensitive))); InclusiveMetricsEvaluator metricsEvaluator = new InclusiveMetricsEvaluator(table.schema(), filterExpr, caseSensitive); + String schemaJson = SchemaParser.toJson(table.schema()); - // Phase 1: partition-prune + cache-load delete manifests into a flat delete-file list. + // Phase 1 (eager): partition-prune + cache-load delete manifests into the delete-file index. List deleteFiles = new ArrayList<>(); for (ManifestFile manifest : snapshot.deleteManifests(table.io())) { if (manifest.content() != ManifestContent.DELETES) { @@ -1809,50 +2014,131 @@ private SplitPlan planFileScanTaskWithManifestCache(TableScan scan, if (!ManifestEvaluator.forPartitionFilter(filterExpr, spec, caseSensitive).eval(manifest)) { continue; } - deleteFiles.addAll(manifestCache.getManifestCacheValue(manifest, table, queryId).getDeleteFiles()); + deleteFiles.addAll(manifestCacheGet(manifest, table, statsQueryId).getDeleteFiles()); } DeleteFileIndex deleteIndex = DeleteFileIndex.builderFor(deleteFiles) .specsById(specsById) .caseSensitive(caseSensitive) .build(); - // Phase 2: partition-prune + cache-load data manifests, then file-level prune + attach deletes. - List tasks = new ArrayList<>(); - try (CloseableIterable dataManifests = - getMatchingManifest(snapshot.dataManifests(table.io()), specsById, filterExpr)) { - for (ManifestFile manifest : dataManifests) { - if (manifest.content() != ManifestContent.DATA) { - continue; - } - PartitionSpec spec = specsById.get(manifest.partitionSpecId()); - if (spec == null) { - continue; - } - ResidualEvaluator residualEvaluator = residualEvaluators.get(manifest.partitionSpecId()); - if (residualEvaluator == null) { - continue; - } - ManifestCacheValue value = manifestCache.getManifestCacheValue(manifest, table, queryId); - for (DataFile dataFile : value.getDataFiles()) { + // Phase 2 (lazy): flat-map the matching data manifests, read through the cache on demand. + CloseableIterable dataManifests = + getMatchingManifest(snapshot.dataManifests(table.io()), specsById, filterExpr); + return new CloseableIterable() { + @Override + public CloseableIterator iterator() { + return new ManifestCacheFileScanTaskIterator(dataManifests.iterator(), table, specsById, + residualEvaluators, metricsEvaluator, deleteIndex, schemaJson, statsQueryId); + } + + @Override + public void close() throws IOException { + dataManifests.close(); + } + }; + } + + /** Dispatch a manifest read to the stats-tallying or no-stats cache overload (see cacheBackedFileScanTasks). */ + private ManifestCacheValue manifestCacheGet(ManifestFile manifest, Table table, String statsQueryId) { + return statsQueryId != null + ? manifestCache.getManifestCacheValue(manifest, table, statsQueryId) + : manifestCache.getManifestCacheValue(manifest, table); + } + + /** + * Lazy flat-map iterator behind {@link #cacheBackedFileScanTasks}: walks the matching data manifests, reads + * each through the manifest cache on demand, and yields the surviving (metrics + residual pruned) + * {@link BaseFileScanTask}s one file at a time so the consumer never holds the whole table's task list. Not + * thread-safe: single-pass, driven by ONE consumer (the sync materialize loop, the streaming pump, or the + * COUNT take-first). {@code schemaJson}/{@code currentSpecJson} are hoisted loop invariants (constant per + * table / per manifest spec), byte-identical to the pre-PERF-04 per-file computation. + */ + private final class ManifestCacheFileScanTaskIterator implements CloseableIterator { + private final CloseableIterator manifestIt; + private final Table table; + private final Map specsById; + private final Map residualEvaluators; + private final InclusiveMetricsEvaluator metricsEvaluator; + private final DeleteFileIndex deleteIndex; + private final String schemaJson; + private final String statsQueryId; + + private Iterator dataFileIt; + private ResidualEvaluator currentResidual; + private String currentSpecJson; + private FileScanTask next; + + ManifestCacheFileScanTaskIterator(CloseableIterator manifestIt, Table table, + Map specsById, Map residualEvaluators, + InclusiveMetricsEvaluator metricsEvaluator, DeleteFileIndex deleteIndex, String schemaJson, + String statsQueryId) { + this.manifestIt = manifestIt; + this.table = table; + this.specsById = specsById; + this.residualEvaluators = residualEvaluators; + this.metricsEvaluator = metricsEvaluator; + this.deleteIndex = deleteIndex; + this.schemaJson = schemaJson; + this.statsQueryId = statsQueryId; + } + + @Override + public boolean hasNext() { + advance(); + return next != null; + } + + @Override + public FileScanTask next() { + advance(); + if (next == null) { + throw new NoSuchElementException(); + } + FileScanTask result = next; + next = null; + return result; + } + + // Fill `next` with the next surviving task, draining the current data manifest's files and advancing to + // further data manifests as needed. Per-file logic mirrors the pre-PERF-04 materialized Phase 2 exactly. + private void advance() { + while (next == null) { + if (dataFileIt != null && dataFileIt.hasNext()) { + DataFile dataFile = dataFileIt.next(); if (!metricsEvaluator.eval(dataFile)) { continue; } - if (residualEvaluator.residualFor(dataFile.partition()).equals(Expressions.alwaysFalse())) { + if (currentResidual.residualFor(dataFile.partition()).equals(Expressions.alwaysFalse())) { continue; } DeleteFile[] deletes = deleteIndex.forDataFile(dataFile.dataSequenceNumber(), dataFile); - tasks.add(new BaseFileScanTask( - dataFile, - deletes, - SchemaParser.toJson(table.schema()), - PartitionSpecParser.toJson(spec), - residualEvaluator)); + next = new BaseFileScanTask(dataFile, deletes, schemaJson, currentSpecJson, currentResidual); + return; + } + if (!manifestIt.hasNext()) { + return; + } + ManifestFile manifest = manifestIt.next(); + if (manifest.content() != ManifestContent.DATA) { + dataFileIt = null; + continue; + } + PartitionSpec spec = specsById.get(manifest.partitionSpecId()); + ResidualEvaluator residual = residualEvaluators.get(manifest.partitionSpecId()); + if (spec == null || residual == null) { + dataFileIt = null; + continue; } + currentResidual = residual; + currentSpecJson = PartitionSpecParser.toJson(spec); + dataFileIt = manifestCacheGet(manifest, table, statsQueryId).getDataFiles().iterator(); } } - long targetSplitSize = determineTargetFileSplitSize(tasks, session); - return new SplitPlan( - TableScanUtil.splitFiles(CloseableIterable.withNoopClose(tasks), targetSplitSize), targetSplitSize); + + @Override + public void close() throws IOException { + manifestIt.close(); + } } /** @@ -2053,17 +2339,36 @@ static ZoneId resolveSessionZone(ConnectorSession session) { * {@code null} context (offline unit tests / simple-auth) resolves directly. */ private Table resolveTable(ConnectorSession session, IcebergTableHandle handle) { - // Resolve the per-request ops before the auth scope so a session=user fail-closed surfaces verbatim. + // Per-statement scope (PERF-07): the statement's read metadata, scan planning and write all resolve the + // SAME one loaded RAW table. The scope holds the RAW table; wrapTableForScan (the Kerberos doAs FileIO) is + // re-applied per call below so no per-request authenticator is ever frozen into the shared object. + // Resolve the per-request ops before the auth scope so a session=user fail-closed surfaces verbatim (it + // re-validates the credential even on a scope hit). IcebergCatalogOps ops = catalogOpsResolver.apply(session); - if (context == null) { - return ops.loadTable(handle.getDbName(), handle.getTableName()); - } - try { - return wrapTableForScan(context.executeAuthenticated( - () -> ops.loadTable(handle.getDbName(), handle.getTableName()))); - } catch (Exception e) { - throw new RuntimeException("Failed to load table for scan, error message is:" + e.getMessage(), e); + Table raw = IcebergStatementScope.sharedTable(session, handle.getDbName(), handle.getTableName(), () -> { + if (context == null) { + return loadRawTable(ops, handle); + } + try { + return context.executeAuthenticated(() -> loadRawTable(ops, handle)); + } catch (Exception e) { + throw new RuntimeException("Failed to load table for scan, error message is:" + e.getMessage(), e); + } + }); + return wrapTableForScan(raw); + } + + /** + * Loads the RAW iceberg table for {@code handle} through the cross-query {@link IcebergTableCache} when + * enabled (the connector disables it for credential-dependent catalogs), else a direct remote + * {@code loadTable}. No wrap and no auth scope here — {@link #resolveTable} owns both. + */ + private Table loadRawTable(IcebergCatalogOps ops, IcebergTableHandle handle) { + if (tableCache != null) { + return tableCache.getOrLoad(TableIdentifier.of(handle.getDbName(), handle.getTableName()), + () -> ops.loadTable(handle.getDbName(), handle.getTableName())); } + return ops.loadTable(handle.getDbName(), handle.getTableName()); } /** 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 new file mode 100644 index 00000000000000..28b0e28fe41d49 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java @@ -0,0 +1,84 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; + +import org.apache.iceberg.Table; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Connector-private helpers over the neutral {@link ConnectorStatementScope} (reached via + * {@link ConnectorSession#getStatementScope()}), giving iceberg one place to key its per-statement state. + * + *

    The scope is the per-statement table-load owner: the read metadata path, scan planning, write shaping + * and {@code beginWrite} all resolve one table through {@link #sharedTable} so a single statement loads each + * table once and every resolver shares that one RAW object (snapshot pins and auth wraps are applied per + * consumer, never frozen into the shared object). It also carries the merge-on-read rewritable-delete supply + * from the scan seam to the write seam ({@link #rewritableDeleteSupply}), replacing the former per-catalog + * singleton stash — the scope is per-statement, so a statement's supply is GC'd with it and a reused + * prepared-statement scope is reset per execution (see {@code ExecuteCommand}).

    + * + *

    Under {@link ConnectorStatementScope#NONE} (offline planning / no live statement) {@link #sharedTable} + * loads every time (byte-identical to the pre-scope behavior) and {@link #rewritableDeleteSupply} returns a + * throwaway map that does NOT bridge scan→write — so a format-version≥3 row-level DML under NONE fails + * loud at the write seam rather than silently resurrecting rows.

    + */ +final class IcebergStatementScope { + + private IcebergStatementScope() {} + + /** + * Loads the RAW iceberg {@link Table} for {@code db.tbl} once per statement and shares it across every + * resolver. The key includes the catalog id (cross-catalog MERGE isolation) and the statement's queryId + * (a reused prepared context sees each execution's own table). {@code loader} runs at most once per + * statement — callers pass the raw load (cross-query cache or direct remote) and own the auth scope + * (the caller wraps this in {@code executeAuthenticated}). + */ + static Table sharedTable(ConnectorSession session, String dbName, String tableName, Supplier loader) { + if (session == null) { + // No session (offline / direct-construction tests): load every time, like ConnectorStatementScope.NONE. + return loader.get(); + } + String key = "iceberg.table:" + session.getCatalogId() + ":" + dbName + ":" + tableName + + ":" + session.getQueryId(); + return session.getStatementScope().computeIfAbsent(key, loader); + } + + /** + * Returns this statement's rewritable-delete supply map (RAW data-file path → its non-equality delete + * descs), creating it empty on first use. The scan seam accumulates into it (per touched data file) and the + * write seam drains it; keyed by catalog id + queryId so a cross-catalog MERGE keeps each table's supply + * isolated. Under {@link ConnectorStatementScope#NONE} each call returns a fresh throwaway map, so scan and + * write do NOT share — the write seam guards format-version≥3 DML against that (fail loud). + */ + static Map> rewritableDeleteSupply(ConnectorSession session) { + if (session == null) { + // No session: a throwaway map that does NOT bridge scan->write (same as NONE). + return new ConcurrentHashMap<>(); + } + String key = "iceberg.rewritable-delete-supply:" + session.getCatalogId() + ":" + session.getQueryId(); + return session.getStatementScope().computeIfAbsent(key, ConcurrentHashMap::new); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java new file mode 100644 index 00000000000000..8a6b53ef599787 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java @@ -0,0 +1,117 @@ +// 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.cache.CacheSpec; +import org.apache.doris.connector.cache.MetaCacheEntry; + +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; + +import java.util.concurrent.ForkJoinPool; +import java.util.function.Supplier; + +/** + * Per-catalog cache of the RAW iceberg {@link Table} object, keyed by {@link TableIdentifier} (db.table) + * (PERF-01). This restores the OTHER half of the legacy {@code IcebergExternalMetaCache} that the SPI cutover + * dropped: {@link IcebergLatestSnapshotCache} kept only the {@code (snapshotId, schemaId)} pin, so every SPI + * read entry ({@code getColumnHandles}, {@code getTableStatistics}, the scan provider's {@code resolveTable}, + * ...) re-loaded the table from the remote catalog (a metastore RPC + a {@code metadata.json} read). This cache + * lets consecutive queries — and the analysis/planning phases of one query, whose handles have distinct memo + * lineages — reuse a single loaded table, exactly as the legacy with-cache catalog did. + * + *

    Backing. Reuses the shared {@link MetaCacheEntry} framework identically to + * {@link IcebergLatestSnapshotCache}: a contextual, access-TTL entry whose per-key loader is supplied at + * {@link #getOrLoad}, with manual miss-load on so the loader runs OUTSIDE Caffeine's compute lock + * (single-flight per key) and propagates its exception verbatim (a concurrent-drop + * {@code NoSuchTableException} reaches the caller unwrapped, preserving each read entry's own degradation). + * TTL is {@code meta.cache.iceberg.table.ttl-second} — the same knob that governs the snapshot cache: a + * value {@code <= 0} disables caching (every read goes live), a positive value is Caffeine + * {@code expireAfterAccess} with a {@code maxSize} capacity. Lives on the long-lived per-catalog + * {@link IcebergConnector}; a REFRESH CATALOG rebuilds the connector and thus the cache. + * + *

    Values are RAW tables. The scan provider applies {@code wrapTableForScan} (the Kerberos + * {@code doAs} FileIO wrap) per call on the way out, so no per-request authenticator is ever frozen into a + * shared entry. + * + *

    Credential isolation. A raw table carries its FileIO's credentials, so this cross-query layer is + * built ONLY when the connector's credentials are query-independent — it is left disabled (the connector + * passes {@code null}) for {@code iceberg.rest.session=user} (per-user delegated FileIO) and REST + * vended-credentials (server-vended tokens expire within the query, and iceberg keeps them fresh by reloading + * the table each query). See {@code IcebergConnector}. + */ +final class IcebergTableCache { + + private final MetaCacheEntry entry; + + IcebergTableCache(long ttlSeconds, int maxSize) { + // Mirror IcebergLatestSnapshotCache: translate the connector's "<= 0 disables" contract to CacheSpec's + // ttl == 0 (disabled) rather than passing a negative value through, which CacheSpec reads as ttl == -1 + // "no expiration (enabled)". + CacheSpec spec = ttlSeconds > 0 + ? CacheSpec.of(true, ttlSeconds, maxSize) + : CacheSpec.of(true, CacheSpec.CACHE_TTL_DISABLE_CACHE, maxSize); + this.entry = new MetaCacheEntry<>("iceberg-table", null, spec, + ForkJoinPool.commonPool(), false, true, 0L, true); + } + + /** Caching is on only when the TTL is positive; ttl-second <= 0 means "always read live". */ + boolean isEnabled() { + return entry.stats().isEffectiveEnabled(); + } + + /** + * 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 + * false) {@code loader} runs every call and nothing is cached. A hit refreshes the entry's expiry + * (access-based). The loader runs OUTSIDE Caffeine's compute lock (single-flight per key) and its exception + * propagates unwrapped. + */ + Table getOrLoad(TableIdentifier identifier, Supplier

    loader) { + return entry.get(identifier, ignored -> loader.get()); + } + + /** Drops the cached entry for one table so the next read goes live (REFRESH TABLE). */ + void invalidate(TableIdentifier identifier) { + entry.invalidateKey(identifier); + } + + /** + * Drops every cached entry for one database so the next read of any of its tables goes live + * (REFRESH DATABASE / a Doris-issued DROP DATABASE). Entries are keyed by + * {@code TableIdentifier.of(db, table)} (single-level namespace = {@code [db]}), so a db match is + * namespace equality — mirroring {@link IcebergLatestSnapshotCache#invalidateDb}. + */ + void invalidateDb(String dbName) { + Namespace ns = Namespace.of(dbName); + entry.invalidateIf(id -> id.namespace().equals(ns)); + } + + /** Drops all cached entries. */ + void invalidateAll() { + entry.invalidateAll(); + } + + /** Test-only: current number of cached entries (accurate map membership, not Caffeine's estimate). */ + int size() { + int[] count = {0}; + entry.forEach((key, value) -> count[0]++); + return count[0]; + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java index aaa3e1ff050c76..4ad5d97c5fd9a6 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java @@ -19,6 +19,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; @@ -91,7 +92,7 @@ * write distribution and the vended-credentials overlay of the hadoop config are registered deviations * (DV-T0x-vended / -broker / -materialize) closed at the P6.6 cutover. At format-version≥3 the DELETE / * MERGE sink's {@code rewritable_delete_file_sets} is filled here from the scan-time supply the - * {@link IcebergRewritableDeleteStash} carried across the scan→write seam (commit-bridge S4 part 2), + * per-statement {@link IcebergStatementScope} carried across the scan→write seam (commit-bridge S4 part 2), * replacing the legacy fe-resident rewritable-delete planner.

    * *

    Gate-closed / dormant. Iceberg is not in {@code SPI_READY_TYPES} until P6.6, so nothing @@ -137,22 +138,12 @@ private static ConnectorColumn buildRowIdColumn() { // single shared ops regardless of session (constant s -> catalogOps). private final Function catalogOpsResolver; private final ConnectorContext context; - // commit-bridge supply (S4 part 2): the per-catalog stash the scan provider filled with each touched data - // file's non-equality delete supply. planWrite retrieves (and evicts) it by queryId to fill the v3 - // rewritable_delete_file_sets. Nullable — null via the 3-arg ctor (offline tests), in which case no supply is - // attached (and the BE would resurrect rows, which is why the cutover wiring injects the real stash). - private final IcebergRewritableDeleteStash rewritableDeleteStash; public IcebergWritePlanProvider(Map properties, IcebergCatalogOps catalogOps, ConnectorContext context) { - this(properties, catalogOps, context, null); - } - - public IcebergWritePlanProvider(Map properties, IcebergCatalogOps catalogOps, - ConnectorContext context, IcebergRewritableDeleteStash rewritableDeleteStash) { // Constant resolver: these ctors (offline tests) bind a single ops that ignores the session, so existing // behaviour/tests are byte-identical. - this(properties, session -> catalogOps, context, rewritableDeleteStash); + this(properties, session -> catalogOps, context); } /** @@ -163,11 +154,10 @@ public IcebergWritePlanProvider(Map properties, IcebergCatalogOp */ public IcebergWritePlanProvider(Map properties, Function catalogOpsResolver, - ConnectorContext context, IcebergRewritableDeleteStash rewritableDeleteStash) { + ConnectorContext context) { this.properties = properties; this.catalogOpsResolver = catalogOpsResolver; this.context = context; - this.rewritableDeleteStash = rewritableDeleteStash; } @Override @@ -183,13 +173,24 @@ public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandl transaction.beginWrite(session, tableHandle.getDbName(), tableHandle.getTableName(), writeContext); Table table = transaction.getTable(); - // commit-bridge supply (S4 part 2): retrieve (and evict) the non-equality delete supply the scan provider - // stashed for this statement. Done once for every write op — DELETE/MERGE attach it to the sink so the BE - // OR-merges old deletes into the new deletion vector (a missing supply silently resurrects deleted rows); - // INSERT/OVERWRITE discard it, but the retrieve still evicts the stash entry (e.g. an INSERT ... SELECT - // FROM an iceberg source). Null when the stash is absent (offline tests) or nothing was stashed. - Map> rewritableDeletes = rewritableDeleteStash != null - ? rewritableDeleteStash.retrieveAndRemove(session.getQueryId()) : null; + // commit-bridge supply (S4 part 2): read the non-equality delete supply the scan seam accumulated into the + // per-statement scope. DELETE/MERGE attach it to the sink so the BE OR-merges old deletes into the new + // deletion vector (a missing supply silently resurrects deleted rows); INSERT/OVERWRITE/REWRITE ignore it + // (buildRewritableDeleteFileSets no-ops an empty map, same as the former null). + // Fail loud (never silently resurrect): a format-version>=3 row-level DML under an absent statement scope + // (ConnectorStatementScope.NONE — offline / no live statement) cannot have received the scan's supply (each + // NONE lookup is a throwaway map), so reject it rather than write a deletion vector that drops old deletes. + WriteOperation writeOp = writeContext.getWriteOperation(); + if ((writeOp == WriteOperation.DELETE || writeOp == WriteOperation.UPDATE || writeOp == WriteOperation.MERGE) + && session.getStatementScope() == ConnectorStatementScope.NONE + && IcebergWriterHelper.getFormatVersion(table) >= 3) { + throw new DorisConnectorException("Iceberg row-level " + writeOp + " on a format-version>=3 table requires " + + "a per-statement scope to carry the rewritable-delete supply from scan to write; none is present " + + "(ConnectorStatementScope.NONE). Refusing to write a deletion vector that would drop old deletes " + + "and resurrect previously-deleted rows."); + } + Map> rewritableDeletes = + IcebergStatementScope.rewritableDeleteSupply(session); // Dispatch on the write operation to the matching BE sink dialect (each is a distinct TDataSinkType, // byte-identical to the legacy fe-core planner sink). OVERWRITE shares TIcebergTableSink with INSERT @@ -687,18 +688,24 @@ private IcebergConnectorTransaction currentTransaction(ConnectorSession session) } private Table resolveTable(ConnectorSession session, IcebergTableHandle handle) { - // Resolve the per-request ops before the auth scope so a session=user fail-closed surfaces verbatim. + // Per-statement scope (PERF-07): the write-shaping derivations (sort columns / partitioning / explain) + // share the SAME one loaded RAW table as the statement's read + scan resolvers. For a row-level DML the + // scan has already populated the scope, so this is a hit (no write-side load); a write-only INSERT loads + // once here. Resolve the per-request ops before the auth scope so a session=user fail-closed surfaces + // verbatim (it re-validates the credential even on a scope hit). IcebergCatalogOps ops = catalogOpsResolver.apply(session); - if (context == null) { - return ops.loadTable(handle.getDbName(), handle.getTableName()); - } - try { - return context.executeAuthenticated( - () -> ops.loadTable(handle.getDbName(), handle.getTableName())); - } catch (Exception e) { - throw new DorisConnectorException("Failed to load iceberg table " - + handle.getDbName() + "." + handle.getTableName() + ": " + e.getMessage(), e); - } + return IcebergStatementScope.sharedTable(session, handle.getDbName(), handle.getTableName(), () -> { + if (context == null) { + return ops.loadTable(handle.getDbName(), handle.getTableName()); + } + try { + return context.executeAuthenticated( + () -> ops.loadTable(handle.getDbName(), handle.getTableName())); + } catch (Exception e) { + throw new DorisConnectorException("Failed to load iceberg table " + + handle.getDbName() + "." + handle.getTableName() + ": " + e.getMessage(), e); + } + }); } private static TFileFormatType toTFileFormatType(FileFormat format) { diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java index 3ac890e7282650..a069a29921b610 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriterHelper.java @@ -34,9 +34,11 @@ import org.apache.iceberg.PartitionData; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.types.Type; @@ -45,6 +47,8 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.ByteBuffer; import java.time.ZoneId; import java.util.ArrayList; @@ -263,8 +267,21 @@ static List convertToDeleteFiles(FileFormat format, PartitionSpec sp * from the current snapshot's data files (defaulting to parquet). Throws on a non-orc/parquet format. */ static FileFormat getFileFormat(Table table) { - Map properties = table.properties(); - String fileFormatName = resolveFileFormatName(table, properties); + return toFileFormat(resolveFileFormatName(table, table.properties())); + } + + /** + * PERF-03 cache-aware overload used by the scan path ({@code IcebergScanPlanProvider.getScanNodeProperties}). + * Identical resolution to {@link #getFileFormat(Table)}, except the whole-table {@code planFiles()} inference + * fallback (fired only when the table sets neither {@code write-format} nor {@code write.format.default}) is + * memoized per {@code (id, currentSnapshotId)} through {@code cache}. The two cheap property probes stay + * uncached. A {@code null} cache/id (offline tests) or an empty table (no current snapshot) resolves live. + */ + static FileFormat getFileFormat(Table table, TableIdentifier id, IcebergFormatCache cache) { + return toFileFormat(resolveFileFormatName(table, table.properties(), id, cache)); + } + + private static FileFormat toFileFormat(String fileFormatName) { if (fileFormatName.toLowerCase().contains(ORC_NAME)) { return FileFormat.ORC; } else if (fileFormatName.toLowerCase().contains(PARQUET_NAME)) { @@ -284,20 +301,70 @@ private static String resolveFileFormatName(Table table, Map pro return inferFileFormatFromDataFiles(table); } + /** + * PERF-03: like {@link #resolveFileFormatName(Table, Map)} but routes the inference fallback (the heavy + * unfiltered {@code planFiles()}) through the cross-query {@code (id, snapshotId)} cache. Only the inference is + * cached; the property probes are cheap. A failed inference (the loader throws) is NOT cached, so the next + * query retries — for this query it degrades to the parquet default, mirroring the swallow of the live path. + */ + private static String resolveFileFormatName(Table table, Map properties, + TableIdentifier id, IcebergFormatCache cache) { + if (properties.containsKey(WRITE_FORMAT)) { + return properties.get(WRITE_FORMAT); + } + if (properties.containsKey(TableProperties.DEFAULT_FILE_FORMAT)) { + return properties.get(TableProperties.DEFAULT_FILE_FORMAT); + } + Snapshot snapshot = table.currentSnapshot(); + if (cache == null || id == null || snapshot == null) { + return inferFileFormatFromDataFiles(table); + } + try { + return cache.getOrLoad(new IcebergFormatCache.Key(id, snapshot.snapshotId()), + () -> inferFirstDataFileFormat(table)); + } catch (RuntimeException e) { + LOG.warn("Failed to infer file format from data files for table {}, defaulting to {}", + table.name(), PARQUET_NAME, e); + return PARQUET_NAME; + } + } + + /** + * The whole-table format inference (the #64134 heavy op): reads the FIRST data file's format from the current + * snapshot via an unfiltered {@code planFiles()}. The swallowing wrapper used by the write path and the direct + * live path (a failure degrades to parquet). The cache loader instead calls {@link #inferFirstDataFileFormat} + * so a transient failure is not memoized. + */ private static String inferFileFormatFromDataFiles(Table table) { if (table.currentSnapshot() == null) { return PARQUET_NAME; } + try { + return inferFirstDataFileFormat(table); + } catch (RuntimeException e) { + LOG.warn("Failed to infer file format from data files for table {}, defaulting to {}", + table.name(), PARQUET_NAME, e); + return PARQUET_NAME; + } + } + + /** + * Raw first-data-file format inference that PROPAGATES failures (unchecked) so the cache loader does not + * memoize a transient remote-IO error. The caller guarantees a non-null current snapshot; an empty snapshot + * (no data files) yields the deterministic parquet default (cacheable). The try-with-resources + * {@code CloseableIterable.close()} checked {@link IOException} is wrapped as {@link UncheckedIOException} so + * the method stays unchecked (a {@code Supplier} loader cannot declare checked throws). + */ + private static String inferFirstDataFileFormat(Table table) { try (CloseableIterable files = table.newScan().planFiles()) { Iterator it = files.iterator(); if (it.hasNext()) { return it.next().file().format().name().toLowerCase(); } - } catch (Exception e) { - LOG.warn("Failed to infer file format from data files for table {}, defaulting to {}", - table.name(), PARQUET_NAME, e); + return PARQUET_NAME; + } catch (IOException e) { + throw new UncheckedIOException(e); } - return PARQUET_NAME; } /** diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java index e6e70530cfef0a..380f77ec65b915 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java @@ -30,6 +30,7 @@ import java.util.Objects; import java.util.concurrent.Callable; import java.util.function.Supplier; +import java.util.function.UnaryOperator; /** * A {@link ConnectorContext} decorator that pins the thread-context classloader (TCCL) to the iceberg plugin @@ -167,6 +168,15 @@ public String normalizeStorageUri(String rawUri, Map rawVendedCr return delegate.normalizeStorageUri(rawUri, rawVendedCredentials); } + @Override + public UnaryOperator newStorageUriNormalizer(Map rawVendedCredentials) { + // Delegate to the raw engine context so the connector gets DefaultConnectorContext's once-per-scan + // hoist. Without this override the SPI default would fold back to THIS wrapper's per-call + // normalizeStorageUri, silently defeating the optimization. Like normalizeStorageUri, this path runs + // entirely in fe-core (LocationPath/StorageProperties, no plugin reflection), so no TCCL pin is needed. + return delegate.newStorageUriNormalizer(rawVendedCredentials); + } + @Override public String getBackendFileType(String rawUri, Map rawVendedCredentials) { return delegate.getBackendFileType(rawUri, rawVendedCredentials); diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsAction.java index 4a3e63b0bc1abf..a10363ccc4c28c 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsAction.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsAction.java @@ -24,6 +24,7 @@ import org.apache.doris.connector.api.pushdown.ConnectorPredicate; import org.apache.doris.foundation.util.ArgumentParsers; +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Lists; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.ExpireSnapshots; @@ -42,8 +43,10 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; @@ -67,6 +70,11 @@ public class IcebergExpireSnapshotsAction extends BaseIcebergAction { public static final String SNAPSHOT_IDS = "snapshot_ids"; public static final String CLEAN_EXPIRED_METADATA = "clean_expired_metadata"; + // Test-only gate for the delete-manifest dedup: the number of DISTINCT delete manifests read by the most + // recent buildDeleteFileContentMap call. Asserts each manifest is read once, not once per referencing snapshot. + @VisibleForTesting + int lastDeleteManifestReadCount; + public IcebergExpireSnapshotsAction(Map properties, List partitionNames, ConnectorPredicate whereCondition) { super("expire_snapshots", properties, partitionNames, whereCondition); @@ -268,8 +276,17 @@ private long parseTimestamp(String timestamp) { } } - private Map buildDeleteFileContentMap(Table icebergTable) { + @VisibleForTesting + Map buildDeleteFileContentMap(Table icebergTable) { Map deleteFileContentByPath = new HashMap<>(); + // Dedup delete-manifest reads across snapshots. Iceberg manifests are immutable and adjacent snapshots + // carry the same delete manifests forward unchanged, so re-reading one yields the identical DeleteFile + // set (putIfAbsent already made the re-read a no-op). Reading each DISTINCT manifest exactly once + // collapses the O(snapshots x manifests) remote reads to O(distinct manifests) with a byte-identical map. + // NOTE: visited MUST live at method scope (outside the snapshot loop) — inside it, it would reset every + // snapshot and defeat the cross-snapshot dedup this fix targets. + Set visitedDeleteManifests = new HashSet<>(); + int reads = 0; try { for (Snapshot snapshot : icebergTable.snapshots()) { List deleteManifests = snapshot.deleteManifests(icebergTable.io()); @@ -277,6 +294,10 @@ private Map buildDeleteFileContentMap(Table icebergTable) { continue; } for (ManifestFile manifest : deleteManifests) { + if (!visitedDeleteManifests.add(manifest.path())) { + continue; + } + reads++; try (CloseableIterable deleteFiles = ManifestFiles.readDeleteManifest( manifest, icebergTable.io(), icebergTable.specs())) { for (DeleteFile deleteFile : deleteFiles) { @@ -289,6 +310,7 @@ private Map buildDeleteFileContentMap(Table icebergTable) { } catch (Exception e) { throw new DorisConnectorException("Failed to build delete file content map: " + e.getMessage(), e); } + lastDeleteManifestReadCount = reads; return deleteFileContentByPath; } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCommentCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCommentCacheTest.java new file mode 100644 index 00000000000000..e8a6f21119e95e --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergCommentCacheTest.java @@ -0,0 +1,177 @@ +// 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.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergCommentCache} (PERF-05). Mirrors {@link IcebergTableCacheTest} but stores the + * {@code comment} string keyed by {@link TableIdentifier}. Covers within-TTL stability, the {@code ttl <= 0} + * disable (load-bearing: a vended no-cache catalog builds this object but must not cache), invalidation, and the + * not-cached-on-failure guarantee that keeps the view-handle {@code NoSuchTableException} degradation to "". + */ +public class IcebergCommentCacheTest { + + private static TableIdentifier id(String db, String tbl) { + return TableIdentifier.of(db, tbl); + } + + @Test + public void cachesWithinTtlAndServesTheSameComment() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + + String first = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "sales fact"; + }); + // Second read of the SAME table within TTL returns the cached comment, NOT a fresh remote loadTable -> this + // is what collapses the per-table information_schema loads on repeat queries. MUTATION: loading live every + // call -> returns "other" / loads==2 -> red. + String second = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "other"; + }); + Assertions.assertEquals("sales fact", first); + Assertions.assertEquals("sales fact", second, "within TTL the cached comment must be served"); + Assertions.assertEquals(1, loads.get(), "the live loadTable must run exactly once within TTL"); + Assertions.assertEquals(1, c.loadCountForTest(), "the metric-gate load count must be 1"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void differentTableIsADifferentKey() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db", "t1"), () -> { + loads.incrementAndGet(); + return "c1"; + }); + String t2 = c.getOrLoad(id("db", "t2"), () -> { + loads.incrementAndGet(); + return "c2"; + }); + Assertions.assertEquals("c2", t2); + Assertions.assertEquals(2, loads.get()); + Assertions.assertEquals(2, c.size()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(0, 1000); + c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "a"; + }); + String second = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "b"; + }); + // ttl-second=0 (a vended no-cache catalog) loads live every time -> operator "no meta cache" intent honored + // even though the connector built this object. MUTATION: caching despite ttl<=0 -> second=="a" / loads==1. + Assertions.assertEquals("b", second, "ttl-second=0 must always load live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(-1, 1000); + c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "a"; + }); + String second = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "b"; + }); + Assertions.assertEquals("b", second, "ttl-second=-1 must always load live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateForcesReload() { + AtomicInteger loads = new AtomicInteger(); + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "old"; + }); + // REFRESH TABLE db.t drops the entry so an external ALTER ... SET TBLPROPERTIES('comment'=...) is picked up. + // MUTATION: invalidate not clearing the key -> "old" survives -> red. + c.invalidate(id("db", "t")); + String after = c.getOrLoad(id("db", "t"), () -> { + loads.incrementAndGet(); + return "new"; + }); + Assertions.assertEquals("new", after); + Assertions.assertEquals(2, loads.get()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db1", "t1"), () -> "a"); + c.getOrLoad(id("db1", "t2"), () -> "b"); + c.getOrLoad(id("db2", "t1"), () -> "c"); + Assertions.assertEquals(3, c.size()); + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's entry must survive REFRESH DATABASE db1"); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + c.getOrLoad(id("db", "t1"), () -> "a"); + c.getOrLoad(id("db", "t2"), () -> "b"); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void loaderFailureIsNotCachedSoNextQueryRetries() { + // A view handle (loadTable throws NoSuchTableException) must NOT be cached, so getTableComment keeps + // degrading to "" via the caller's catch on every call (and a real table appearing later loads fresh). The + // MetaCacheEntry manual-miss-load path re-throws the loader's exception verbatim and does not store it. + // MUTATION: caching on failure -> the second call would not re-run the loader / size==1 -> red. + IcebergCommentCache c = new IcebergCommentCache(100, 1000); + AtomicInteger loads = new AtomicInteger(); + Assertions.assertThrows(NoSuchTableException.class, () -> c.getOrLoad(id("db", "v"), () -> { + loads.incrementAndGet(); + throw new NoSuchTableException("not a table: db.v"); + })); + Assertions.assertEquals(0, c.size(), "a thrown comment load must not be cached"); + String ok = c.getOrLoad(id("db", "v"), () -> { + loads.incrementAndGet(); + return "now a table"; + }); + Assertions.assertEquals("now a table", ok); + Assertions.assertEquals(2, loads.get(), "the loader must re-run after a failure (no sticky failure)"); + Assertions.assertEquals(1, c.size()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java index bc816df8c48e22..0a22ffff2fb6e4 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorCacheTest.java @@ -128,6 +128,48 @@ public void invalidateHooksAreNoThrowOnFreshConnector() { Assertions.assertDoesNotThrow(connector::invalidateAll); } + @Test + public void latestSnapshotCacheDisabledForSessionUser() { + // The latest-snapshot cache is an AUTHORIZATION-sensitive projection (snapshotId/schemaId) that + // beginQuerySnapshot reads WITHOUT a preceding per-user loadTable, so a shared hit would bypass the + // per-user authorization. It is disabled (null) under iceberg.rest.session=user (kept otherwise, incl. + // vended-credentials, since a snapshot id carries no token). MUTATION: dropping the session=user gate -> + // non-null for session -> red. + Assertions.assertNotNull( + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()) + .latestSnapshotCacheForTest(), + "a plain catalog builds the latest-snapshot cache"); + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNotNull( + new IcebergConnector(vended, new RecordingConnectorContext()).latestSnapshotCacheForTest(), + "a vended-credentials catalog still builds the latest-snapshot cache (an id carries no token)"); + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).latestSnapshotCacheForTest(), + "a session=user catalog must NOT build the latest-snapshot cache (per-user authz bypass)"); + } + + @Test + public void invalidateHooksAreNoThrowForSessionUserWithNulledCaches() { + // Under session=user the latest-snapshot / partition / format caches are all null. The REFRESH hooks must + // still be no-throw (the invalidate* methods null-guard each cache). MUTATION: an unguarded invalidate call + // on a nulled cache -> NPE -> red. + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + IcebergConnector connector = new IcebergConnector(session, new RecordingConnectorContext()); + Assertions.assertNull(connector.latestSnapshotCacheForTest()); + Assertions.assertNull(connector.partitionCacheForTest()); + Assertions.assertNull(connector.formatCacheForTest()); + Assertions.assertDoesNotThrow(() -> connector.invalidateTable("db1", "t1")); + Assertions.assertDoesNotThrow(() -> connector.invalidateDb("db1")); + Assertions.assertDoesNotThrow(connector::invalidateAll); + } + @Test public void refreshCatalogInvalidateAllDropsManifestCache() { // H-5: REFRESH CATALOG -> Connector.invalidateAll() must drop the connector's OWN manifest cache too @@ -165,4 +207,248 @@ private static Table tableWithOneManifest() { .commit(); return table; } + + // ==================== PERF-01: cross-query table cache gate + invalidation ==================== + + private static Table fakeTable(String name) { + return new FakeIcebergTable(name, + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())), + PartitionSpec.unpartitioned(), "s3://b/" + name, Collections.emptyMap()); + } + + @Test + public void crossQueryTableCacheEnabledForPlainCatalog() { + // A plain catalog (no per-user session, no REST vended credentials) has query-independent credentials, + // so the cross-query RAW-table cache is built and enabled at the default 24h TTL — restoring the legacy + // IcebergExternalMetaCache table cache. MUTATION: leaving it null/disabled for a plain catalog -> the + // 3~7x remote loadTable amplification is not collapsed across queries -> assert below red. + IcebergTableCache cache = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()).tableCacheForTest(); + Assertions.assertNotNull(cache, "a plain catalog must build the cross-query table cache"); + Assertions.assertTrue(cache.isEnabled(), "the default 24h TTL enables the cache"); + } + + @Test + public void crossQueryTableCacheDisabledForVendedCredentials() { + // REST vended-credentials: the cached raw table's FileIO carries a server-vended token that expires + // within the query (iceberg keeps it fresh by reloading the table each query). A 24h-TTL cross-query hit + // would hand BE an expired token (403 mid-scan), so this layer MUST be off (null); the query-scoped fat + // handle still dedups within one query. MUTATION: building the cache for a vended catalog -> non-null -> red. + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNull( + new IcebergConnector(vended, new RecordingConnectorContext()).tableCacheForTest(), + "a REST vended-credentials catalog must NOT build the cross-query table cache"); + } + + @Test + public void crossQueryTableCacheDisabledForPerUserSession() { + // iceberg.rest.session=user: the cached raw table carries per-user delegated FileIO, so sharing it + // across users would leak credentials. This layer MUST be off (null) — the fat handle keeps within-query + // dedup. MUTATION: building the cache for a session=user catalog -> tableCacheForTest non-null -> red. + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).tableCacheForTest(), + "a per-user session catalog must NOT build the cross-query table cache"); + } + + @Test + public void refreshHooksInvalidateCrossQueryTableCache() { + // The REFRESH hooks must clear the cross-query table cache (else external DDL/writes would stay invisible + // beyond the pin): REFRESH TABLE drops one table, REFRESH DATABASE drops that db's tables, REFRESH + // CATALOG drops everything — mirroring the latest-snapshot cache. MUTATION: an invalidate* hook not + // touching tableCache -> a stale entry survives -> a size assert below red. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + IcebergTableCache cache = connector.tableCacheForTest(); + Assertions.assertNotNull(cache); + + cache.getOrLoad(TableIdentifier.of("db1", "t1"), () -> fakeTable("db1.t1")); + cache.getOrLoad(TableIdentifier.of("db1", "t2"), () -> fakeTable("db1.t2")); + cache.getOrLoad(TableIdentifier.of("db2", "t1"), () -> fakeTable("db2.t1")); + Assertions.assertEquals(3, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops only that table"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops that db's remaining tables"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } + + // ============ PERF-02: partition-view cache (session=user gated) + invalidation ============ + + private static IcebergPartitionCache.Key partKey(String db, String tbl, long snapshotId) { + return new IcebergPartitionCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + @Test + public void partitionCacheBuiltUnlessSessionUser() { + // The partition-view cache stores pure metadata (no FileIO/credential), so unlike the table cache it stays + // built for a REST vended-credentials catalog (a partition list carries no token). But under + // iceberg.rest.session=user it is an AUTHORIZATION-sensitive projection -- a shared (no user dimension) hit + // would disclose one user's partitions to a "can-list-cannot-load" principal -- so it is disabled (null) + // there, holding the "session=user => no live cross-query metadata cache" invariant. + // MUTATION: dropping the session=user gate -> non-null for session -> red; gating on the vended flag -> + // null for vended -> red. + Assertions.assertNotNull( + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()).partitionCacheForTest(), + "a plain catalog builds the partition cache"); + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNotNull( + new IcebergConnector(vended, new RecordingConnectorContext()).partitionCacheForTest(), + "a vended-credentials catalog still builds the partition cache (metadata carries no credentials)"); + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).partitionCacheForTest(), + "a session=user catalog must NOT build the partition cache (per-user authz must not be bypassed)"); + } + + @Test + public void refreshHooksInvalidatePartitionCache() { + // The REFRESH hooks must clear the partition-view cache too (else external DDL/writes would stay invisible + // beyond the pin): REFRESH TABLE drops that table's snapshot entries, REFRESH DATABASE that db's, REFRESH + // CATALOG everything. MUTATION: an invalidate* hook not touching partitionCache -> a stale entry survives + // -> a size assert below red. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + IcebergPartitionCache cache = connector.partitionCacheForTest(); + Assertions.assertNotNull(cache); + cache.getOrLoad(partKey("db1", "t1", 1L), Collections::emptyList); + cache.getOrLoad(partKey("db1", "t1", 2L), Collections::emptyList); + cache.getOrLoad(partKey("db1", "t2", 1L), Collections::emptyList); + cache.getOrLoad(partKey("db2", "t1", 1L), Collections::emptyList); + Assertions.assertEquals(4, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops both snapshot entries of db1.t1"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops db1's remaining table"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } + + private static IcebergFormatCache.Key fmtKey(String db, String tbl, long snapshotId) { + return new IcebergFormatCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + @Test + public void formatCacheBuiltUnlessSessionUser() { + // The inferred-format cache stores a pure metadata format-name string (no FileIO/credential), so like the + // partition cache it stays built for a REST vended-credentials catalog. But under iceberg.rest.session=user + // it is an AUTHORIZATION-sensitive projection, so it is disabled (null) there (same treatment as the + // partition cache). MUTATION: dropping the session=user gate -> non-null for session -> red; gating on the + // vended flag -> null for vended -> red. + Assertions.assertNotNull( + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()).formatCacheForTest(), + "a plain catalog builds the format cache"); + Map vended = new HashMap<>(); + vended.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + vended.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + Assertions.assertNotNull( + new IcebergConnector(vended, new RecordingConnectorContext()).formatCacheForTest(), + "a vended-credentials catalog still builds the format cache (a format name carries no credentials)"); + Map session = new HashMap<>(); + session.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + session.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + Assertions.assertNull( + new IcebergConnector(session, new RecordingConnectorContext()).formatCacheForTest(), + "a session=user catalog must NOT build the format cache (per-user authz must not be bypassed)"); + } + + @Test + public void refreshHooksInvalidateFormatCache() { + // The REFRESH hooks must clear the inferred-format cache too (else a rewrite that changed the write format + // would stay invisible beyond the pin): REFRESH TABLE drops that table's snapshot entries, REFRESH DATABASE + // that db's, REFRESH CATALOG everything. MUTATION: an invalidate* hook not touching formatCache -> a stale + // entry survives -> a size assert below red. + IcebergConnector connector = + new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); + IcebergFormatCache cache = connector.formatCacheForTest(); + Assertions.assertNotNull(cache); + cache.getOrLoad(fmtKey("db1", "t1", 1L), () -> "parquet"); + cache.getOrLoad(fmtKey("db1", "t1", 2L), () -> "orc"); + cache.getOrLoad(fmtKey("db1", "t2", 1L), () -> "parquet"); + cache.getOrLoad(fmtKey("db2", "t1", 1L), () -> "parquet"); + Assertions.assertEquals(4, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops both snapshot entries of db1.t1"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops db1's remaining table"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } + + private static Map restProps(boolean vended, boolean sessionUser) { + Map m = new HashMap<>(); + m.put(IcebergConnectorProperties.ICEBERG_CATALOG_TYPE, IcebergConnectorProperties.TYPE_REST); + if (vended) { + m.put(IcebergConnectorProperties.REST_VENDED_CREDENTIALS_ENABLED, "true"); + } + if (sessionUser) { + m.put(IcebergConnectorProperties.REST_SESSION, IcebergConnectorProperties.SESSION_USER); + } + return m; + } + + private static IcebergCommentCache commentCacheOf(Map props) { + return new IcebergConnector(props, new RecordingConnectorContext()).commentCacheForTest(); + } + + @Test + public void commentCacheBuiltOnlyForVendedNonSessionCatalog() { + // PERF-05: the comment cache fills the gap PERF-01's tableCache leaves for vended-credentials catalogs, but + // ONLY when NOT session=user -- a session=user comment cache would serve one user's comment to another + // whose per-user loadTable authorization was never checked (a metadata disclosure). Plain catalogs already + // reuse tableCache for the comment path, so no comment cache there either. + // Plain catalog -> null (tableCache covers the comment path; no redundant cache). + Assertions.assertNull(commentCacheOf(Collections.emptyMap()), + "a plain catalog must NOT build the comment cache (tableCache already serves it)"); + // Vended, non-session -> built (the one flavor it is safe + useful for). + Assertions.assertNotNull(commentCacheOf(restProps(true, false)), + "a vended-credentials (non-session) catalog must build the comment cache"); + // session=user -> null (per-user authorization must not be bypassed by a shared cache). + Assertions.assertNull(commentCacheOf(restProps(false, true)), + "a session=user catalog must NOT build the comment cache (per-user authz)"); + // vended AND session=user -> null (session=user wins; !isUserSessionEnabled() gates it off). + Assertions.assertNull(commentCacheOf(restProps(true, true)), + "vended + session=user must NOT build the comment cache (session=user takes precedence)"); + } + + @Test + public void refreshHooksInvalidateCommentCache() { + // The REFRESH hooks must clear the comment cache too (else an external ALTER comment stays invisible beyond + // the pin): REFRESH TABLE drops that table, REFRESH DATABASE that db, REFRESH CATALOG everything. MUTATION: + // an invalidate* hook not touching commentCache -> a stale comment survives -> a size assert below red. + IcebergConnector connector = new IcebergConnector(restProps(true, false), new RecordingConnectorContext()); + IcebergCommentCache cache = connector.commentCacheForTest(); + Assertions.assertNotNull(cache); + cache.getOrLoad(TableIdentifier.of("db1", "t1"), () -> "c1"); + cache.getOrLoad(TableIdentifier.of("db1", "t2"), () -> "c2"); + cache.getOrLoad(TableIdentifier.of("db2", "t1"), () -> "c3"); + Assertions.assertEquals(3, cache.size()); + + connector.invalidateTable("db1", "t1"); + Assertions.assertEquals(2, cache.size(), "REFRESH TABLE drops db1.t1"); + + connector.invalidateDb("db1"); + Assertions.assertEquals(1, cache.size(), "REFRESH DATABASE drops db1's remaining table"); + + connector.invalidateAll(); + Assertions.assertEquals(0, cache.size(), "REFRESH CATALOG drops everything"); + } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java index 60e4b47c718951..876e84d9a55a24 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataTest.java @@ -156,6 +156,34 @@ private static IcebergConnectorMetadata metadataWithTableProps(Map each call reloads -> loadCountForTest > 1 / >1 remote "loadTable:" -> red. + Map props = new HashMap<>(); + props.put("comment", "sales fact"); + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + ops.table = new FakeIcebergTable( + "t1", idNameSchema(), PartitionSpec.unpartitioned(), "s3://bucket/db1/t1", props); + IcebergCommentCache cache = new IcebergCommentCache(100, 1000); + IcebergConnectorMetadata metadata = new IcebergConnectorMetadata(ops, Collections.emptyMap(), + new RecordingConnectorContext(), new IcebergLatestSnapshotCache(0L, 1), null, null, cache); + + Assertions.assertEquals("sales fact", metadata.getTableComment(null, "db1", "t1")); + Assertions.assertEquals("sales fact", metadata.getTableComment(null, "db1", "t1")); + Assertions.assertEquals("sales fact", metadata.getTableComment(null, "db1", "t1")); + + Assertions.assertEquals(1, cache.loadCountForTest(), + "repeated getTableComment at one table must load the comment exactly once"); + long remoteLoads = ops.log.stream().filter(s -> s.startsWith("loadTable:db1.t1")).count(); + Assertions.assertEquals(1, remoteLoads, "exactly one remote loadTable across the repeats"); + // Parity: the cached value equals a direct (uncached) read of the comment property. + Assertions.assertEquals("sales fact", + metadataWithTableProps(props).getTableComment(null, "db1", "t1")); + } + private static void assertCopyOnWriteRejected( IcebergConnectorMetadata md, WriteOperation op, String operationLabel, String property) { DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergFormatCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergFormatCacheTest.java new file mode 100644 index 00000000000000..be8483a44e11c7 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergFormatCacheTest.java @@ -0,0 +1,181 @@ +// 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.iceberg.catalog.TableIdentifier; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergFormatCache} (PERF-03). Mirrors {@link IcebergPartitionCacheTest} but keys by + * {@code (TableIdentifier, snapshotId)} and stores the inferred format-name String. Covers within-TTL stability, + * the {@code ttl <= 0} disable, invalidation, and the not-cached-on-failure guarantee that makes a transient + * remote-IO failure retry on the next query (legacy parity). + */ +public class IcebergFormatCacheTest { + + private static IcebergFormatCache.Key key(String db, String tbl, long snapshotId) { + return new IcebergFormatCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + @Test + public void cachesWithinTtlAndServesTheSameValue() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + + String first = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + // Second read of the SAME (table, snapshot) within TTL returns the cached format, NOT a fresh whole-table + // planFiles() inference -> this is what collapses the per-query #64134 fallback. MUTATION: inferring live + // every call -> returns "parquet" / loads==2 -> red. + String second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + Assertions.assertEquals("orc", first); + Assertions.assertEquals("orc", second, "within TTL the cached format must be served"); + Assertions.assertEquals(1, loads.get(), "the live inference must run exactly once within TTL"); + Assertions.assertEquals(1, c.loadCountForTest(), "the metric-gate load count must be 1"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void differentSnapshotIdIsADifferentKey() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + // A new commit yields a new snapshot id -> a distinct key -> a live inference (freshness across snapshots, + // e.g. a rewrite that changed the write format). MUTATION: keying by table only -> loads==1 -> red. + String s2 = c.getOrLoad(key("db", "t", 2L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + Assertions.assertEquals("orc", s2); + Assertions.assertEquals(2, loads.get()); + Assertions.assertEquals(2, c.size()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(0, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + String second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + // ttl-second=0 (the no-cache catalog) infers live every time. MUTATION: caching despite ttl<=0 -> + // second=="orc" / loads==1 -> red. + Assertions.assertEquals("parquet", second, "ttl-second=0 must always infer live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(-1, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + String second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "parquet"; + }); + Assertions.assertEquals("parquet", second, "ttl-second=-1 must always infer live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateDropsAllSnapshotsOfOneTable() { + AtomicInteger loads = new AtomicInteger(); + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> "parquet"); + c.getOrLoad(key("db", "t", 2L), () -> "orc"); + c.getOrLoad(key("db", "other", 1L), () -> "parquet"); + Assertions.assertEquals(3, c.size()); + + // REFRESH TABLE db.t must drop BOTH snapshot entries of db.t and leave db.other intact. MUTATION: + // invalidating a single (table, snapshot) key -> the other snapshot survives -> size 2 here -> red. + c.invalidate(TableIdentifier.of("db", "t")); + Assertions.assertEquals(1, c.size(), "only db.other's entry must survive"); + String reload = c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + Assertions.assertEquals("orc", reload); + Assertions.assertEquals(1, loads.get()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db1", "t1", 1L), () -> "parquet"); + c.getOrLoad(key("db1", "t2", 1L), () -> "orc"); + c.getOrLoad(key("db2", "t1", 1L), () -> "parquet"); + Assertions.assertEquals(3, c.size()); + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's entry must survive REFRESH DATABASE db1"); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + c.getOrLoad(key("db", "t1", 1L), () -> "parquet"); + c.getOrLoad(key("db", "t2", 1L), () -> "orc"); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void loaderFailureIsNotCachedSoNextQueryRetries() { + // A transient remote-IO failure during inference (planFiles/close) must NOT be memoized, or the parquet + // fallback would stick for the whole TTL. The MetaCacheEntry manual-miss-load path re-throws the loader's + // RuntimeException verbatim and does not store it. MUTATION: caching on failure -> the second call would + // not re-run the loader / size==1 -> red. + IcebergFormatCache c = new IcebergFormatCache(100, 1000); + AtomicInteger loads = new AtomicInteger(); + Assertions.assertThrows(RuntimeException.class, () -> c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + throw new RuntimeException("transient manifest read failure"); + })); + Assertions.assertEquals(0, c.size(), "a failed inference must not be cached"); + // The next query at the same key re-runs the loader (retry), and a success is then cached. + String ok = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return "orc"; + }); + Assertions.assertEquals("orc", ok); + Assertions.assertEquals(2, loads.get(), "the loader must re-run after a failure (no sticky failure)"); + Assertions.assertEquals(1, c.size()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionCacheTest.java new file mode 100644 index 00000000000000..576d46151c7a42 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionCacheTest.java @@ -0,0 +1,186 @@ +// 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.iceberg.IcebergPartitionUtils.IcebergRawPartition; + +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.ValidationException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergPartitionCache} (PERF-02). Mirrors {@link IcebergTableCacheTest} but keys by + * {@code (TableIdentifier, snapshotId)} and stores the raw partition list. Covers within-TTL stability, the + * {@code ttl <= 0} disable, invalidation, and the exception-propagation guarantee that {@code listPartitions}' + * dropped-partition-source-column degradation depends on. + */ +public class IcebergPartitionCacheTest { + + private static IcebergPartitionCache.Key key(String db, String tbl, long snapshotId) { + return new IcebergPartitionCache.Key(TableIdentifier.of(db, tbl), snapshotId); + } + + /** A raw partition list of the given size, distinguishable by size. */ + private static List raws(int n) { + List list = new java.util.ArrayList<>(); + for (int i = 0; i < n; i++) { + list.add(new IcebergRawPartition("p" + i, Collections.singletonList("c"), + Collections.singletonList("v" + i), Collections.singletonList("identity"), 0L, 0L)); + } + return list; + } + + @Test + public void cachesWithinTtlAndServesTheSameList() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + + List first = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(3); + }); + // Second read of the SAME (table, snapshot) within TTL returns the cached list, NOT a fresh scan -> this + // is what collapses the per-query and per-MTMV-refresh PARTITIONS scans. MUTATION: scanning live every + // call -> returns the 7-element list / loads==2 -> red. + List second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(7); + }); + Assertions.assertEquals(3, first.size()); + Assertions.assertSame(first, second, "within TTL the cached partition list must be served"); + Assertions.assertEquals(1, loads.get(), "the live scan must run exactly once within TTL"); + Assertions.assertEquals(1, c.loadCountForTest(), "the metric-gate load count must be 1"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void differentSnapshotIdIsADifferentKey() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return raws(1); + }); + // A new commit yields a new snapshot id -> a distinct key -> a live scan (freshness across snapshots). + // MUTATION: keying by table only (ignoring snapshotId) -> loads==1 / serves the snapshot-1 list -> red. + List s2 = c.getOrLoad(key("db", "t", 2L), () -> { + loads.incrementAndGet(); + return raws(9); + }); + Assertions.assertEquals(9, s2.size()); + Assertions.assertEquals(2, loads.get()); + Assertions.assertEquals(2, c.size()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(0, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(3); + }); + List second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(7); + }); + // ttl-second=0 (the no-cache catalog) scans live every time. MUTATION: caching despite ttl<=0 -> + // second.size()==3 / loads==1 -> red. + Assertions.assertEquals(7, second.size(), "ttl-second=0 must always scan live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(-1, 1000); + c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(3); + }); + List second = c.getOrLoad(key("db", "t", 5L), () -> { + loads.incrementAndGet(); + return raws(7); + }); + Assertions.assertEquals(7, second.size(), "ttl-second=-1 must always scan live"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateDropsAllSnapshotsOfOneTable() { + AtomicInteger loads = new AtomicInteger(); + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db", "t", 1L), () -> raws(1)); + c.getOrLoad(key("db", "t", 2L), () -> raws(2)); + c.getOrLoad(key("db", "other", 1L), () -> raws(3)); + Assertions.assertEquals(3, c.size()); + + // REFRESH TABLE db.t must drop BOTH snapshot entries of db.t and leave db.other intact. MUTATION: + // invalidateKey on a single (table, snapshot) key -> the other snapshot survives -> size 2 here -> red. + c.invalidate(TableIdentifier.of("db", "t")); + Assertions.assertEquals(1, c.size(), "only db.other's entry must survive"); + List reload = c.getOrLoad(key("db", "t", 1L), () -> { + loads.incrementAndGet(); + return raws(9); + }); + Assertions.assertEquals(9, reload.size()); + Assertions.assertEquals(1, loads.get()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db1", "t1", 1L), () -> raws(1)); + c.getOrLoad(key("db1", "t2", 1L), () -> raws(2)); + c.getOrLoad(key("db2", "t1", 1L), () -> raws(3)); + Assertions.assertEquals(3, c.size()); + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's entry must survive REFRESH DATABASE db1"); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + c.getOrLoad(key("db", "t1", 1L), () -> raws(1)); + c.getOrLoad(key("db", "t2", 1L), () -> raws(2)); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void loaderExceptionPropagatesUnwrapped() { + // listPartitions catches ValidationException (dropped partition source column) to degrade to an empty + // list. Routing the scan through this cache must NOT wrap it, or the degradation would break. The + // MetaCacheEntry manual-miss-load path re-throws the loader's RuntimeException verbatim, and a failed + // scan is not cached. MUTATION: wrapping the loader exception -> assertThrows(ValidationException) fails. + IcebergPartitionCache c = new IcebergPartitionCache(100, 1000); + Assertions.assertThrows(ValidationException.class, () -> c.getOrLoad(key("db", "t", 5L), () -> { + throw new ValidationException("Cannot find source column for partition field"); + })); + Assertions.assertEquals(0, c.size(), "a failed scan must not be cached"); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java index e41ab6246a69a0..cb42feafd95cb2 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergPartitionUtilsTest.java @@ -824,6 +824,35 @@ public void buildMvccPartitionViewEnumeratesRangePartitions() { "a committed RANGE table must report a positive newest-update-time for dictionary refresh"); } + @Test + public void partitionScanIsCachedAcrossRepeatsAndConsumersAtSameSnapshot() { + // PERF-02 metric gate: buildMvccPartitionView (MTMV/RANGE) and listPartitions (selectedPartitionNum) both + // funnel through loadRawPartitions keyed by (table, currentSnapshotId). A shared IcebergPartitionCache must + // collapse repeated calls -- across queries AND across the two consumers at the same snapshot -- onto ONE + // remote PARTITIONS scan (restoring legacy cross-query partition-info caching + collapsing the MTMV 4~6x + // re-enumeration). MUTATION: not threading the cache into loadRawPartitions -> each call re-scans -> + // loadCountForTest > 1 -> red. + PartitionSpec spec = PartitionSpec.builderFor(RELATED_SCHEMA).day("ts").build(); + Table table = dayPartitionedTable(spec, "ts_day=1970-04-11", "ts_day=1970-07-20"); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergPartitionCache cache = new IcebergPartitionCache(100, 1000); + + ConnectorMvccPartitionView v1 = IcebergPartitionUtils.buildMvccPartitionView(table, -1L, id, cache); + IcebergPartitionUtils.buildMvccPartitionView(table, -1L, id, cache); + List parts = IcebergPartitionUtils.listPartitions(table, id, cache); + + Assertions.assertEquals(1, cache.loadCountForTest(), + "the PARTITIONS scan must run exactly once across repeated views + both consumers at one snapshot"); + Assertions.assertEquals(1, cache.size(), "one (table, snapshot) entry"); + Assertions.assertEquals(2, parts.size(), "listPartitions still returns the two physical partitions"); + // Parity: the cached view matches a fresh (uncached) enumeration. + List cachedNames = v1.getPartitions().stream() + .map(ConnectorMvccPartition::getName).collect(Collectors.toList()); + List liveNames = IcebergPartitionUtils.buildMvccPartitionView(table, -1L).getPartitions().stream() + .map(ConnectorMvccPartition::getName).collect(Collectors.toList()); + Assertions.assertEquals(liveNames, cachedNames, "cached partition view must equal a live enumeration"); + } + @Test public void buildMvccPartitionViewResolvesPerPartitionSnapshotId() { // Two SEPARATE commits: ts_day=100 lands in snapshot S1, ts_day=200 in S2. Each partition's freshness is diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProviderSessionRoutingTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProviderSessionRoutingTest.java index 632a112f23dbdf..319bebec8600c5 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProviderSessionRoutingTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProviderSessionRoutingTest.java @@ -108,7 +108,7 @@ public void scanProviderRoutesCredentialedSessionToPerUserOps() { public void writeProviderAppliesResolverWithCallSessionAndFailsClosed() { List seen = new ArrayList<>(); IcebergWritePlanProvider provider = new IcebergWritePlanProvider(Collections.emptyMap(), - failClosedResolver(seen, new RecordingIcebergCatalogOps()), null, null); + failClosedResolver(seen, new RecordingIcebergCatalogOps()), null); RoutingSession noCred = new RoutingSession(null); DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRewritableDeleteStashTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRewritableDeleteStashTest.java deleted file mode 100644 index 8f1b40dff9a87f..00000000000000 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRewritableDeleteStashTest.java +++ /dev/null @@ -1,204 +0,0 @@ -// 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.thrift.TIcebergDeleteFileDesc; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Unit-pins {@link IcebergRewritableDeleteStash}, the scan→write seam carrier for a row-level DML's - * non-equality delete supply. WHY each invariant matters: the BE OR-merges the supplied old deletes into the - * new deletion vector, so a DROPPED or WRONG supply silently resurrects previously-deleted rows; an over-stash - * (a plain SELECT that never writes) must NOT grow unboundedly. The clock is injected so the TTL sweep is - * deterministic without sleeping. - */ -public class IcebergRewritableDeleteStashTest { - - private static TIcebergDeleteFileDesc desc(String path, int content) { - TIcebergDeleteFileDesc d = new TIcebergDeleteFileDesc(); - d.setPath(path); - d.setContent(content); - return d; - } - - private static List descs(String path, int content) { - return Collections.singletonList(desc(path, content)); - } - - /** A stash whose clock the test drives by hand (nanos), so TTL expiry is exact. */ - private static IcebergRewritableDeleteStash stashWithClock(long ttlSeconds, AtomicLong nanos) { - return new IcebergRewritableDeleteStash(ttlSeconds, nanos::get); - } - - @Test - public void retrieveReturnsWhatWasAccumulatedKeyedByRawDataFilePath() { - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("q1", "s3://b/db/t/f1.parquet", descs("s3://b/db/t/dv1.puffin", 3)); - - Map> sets = stash.retrieveAndRemove("q1"); - Assertions.assertNotNull(sets); - Assertions.assertEquals(1, sets.size()); - // The KEY is the raw data-file path (the string the BE matches against). A mutation keying on anything - // else loses the lookup -> resurrection. - Assertions.assertTrue(sets.containsKey("s3://b/db/t/f1.parquet")); - Assertions.assertEquals("s3://b/db/t/dv1.puffin", sets.get("s3://b/db/t/f1.parquet").get(0).getPath()); - } - - @Test - public void retrieveAndRemoveEvictsSoASecondRetrieveIsEmpty() { - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("q1", "s3://b/db/t/f1.parquet", descs("dv", 3)); - - Assertions.assertNotNull(stash.retrieveAndRemove("q1")); - // The retrieve is the primary eviction. MUTATION: making retrieve NOT remove -> this second read is - // non-null and the entry leaks across statements. - Assertions.assertNull(stash.retrieveAndRemove("q1")); - Assertions.assertEquals(0, stash.size()); - } - - @Test - public void retrieveUnknownQueryIdReturnsNull() { - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - Assertions.assertNull(stash.retrieveAndRemove("never-stashed")); - } - - @Test - public void accumulateMergesDistinctDataFilesUnderOneQuery() { - // A MERGE scans two tables under one queryId; their data-file paths are globally distinct, so both must - // survive in one entry. MUTATION: overwriting (put under the queryId) instead of merging per path drops - // the first table's supply -> resurrection on that table. - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("q1", "s3://b/db/target/f.parquet", descs("dv-target", 3)); - stash.accumulate("q1", "s3://b/db/source/g.parquet", descs("dv-source", 3)); - - Map> sets = stash.retrieveAndRemove("q1"); - Assertions.assertEquals(2, sets.size()); - Assertions.assertTrue(sets.containsKey("s3://b/db/target/f.parquet")); - Assertions.assertTrue(sets.containsKey("s3://b/db/source/g.parquet")); - } - - @Test - public void accumulateSamePathIsIdempotentForSplitRanges() { - // A large data file split into several ranges carries an identical delete list per split; re-putting the - // same key must not duplicate. Last-wins (same value) keeps exactly one entry. - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - List d = descs("dv", 3); - stash.accumulate("q1", "s3://b/db/t/f.parquet", d); - stash.accumulate("q1", "s3://b/db/t/f.parquet", d); - - Map> sets = stash.retrieveAndRemove("q1"); - Assertions.assertEquals(1, sets.size()); - Assertions.assertEquals(1, sets.get("s3://b/db/t/f.parquet").size()); - } - - @Test - public void twoQueryIdsAreIsolated() { - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("q1", "s3://b/db/t/f1.parquet", descs("dv1", 3)); - stash.accumulate("q2", "s3://b/db/t/f2.parquet", descs("dv2", 3)); - - Assertions.assertTrue(stash.retrieveAndRemove("q1").containsKey("s3://b/db/t/f1.parquet")); - // q1's retrieve must not touch q2. - Assertions.assertTrue(stash.retrieveAndRemove("q2").containsKey("s3://b/db/t/f2.parquet")); - } - - @Test - public void blankQueryIdIsNeverStashed() { - // A null/absent ConnectContext coerces queryId to "" — two concurrent such statements would collide on - // "" and read each other's (or stale) supply. MUTATION: dropping the blank guard lets the "" key store a - // map that a later null-ctx statement reads -> stale supply / resurrection. - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("", "s3://b/db/t/f.parquet", descs("dv", 3)); - stash.accumulate(null, "s3://b/db/t/f.parquet", descs("dv", 3)); - - Assertions.assertEquals(0, stash.size()); - Assertions.assertNull(stash.retrieveAndRemove("")); - Assertions.assertNull(stash.retrieveAndRemove(null)); - } - - @Test - public void emptyOrNullSupplyIsNotStashed() { - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("q1", "s3://b/db/t/f.parquet", Collections.emptyList()); - stash.accumulate("q1", "s3://b/db/t/f.parquet", null); - - Assertions.assertEquals(0, stash.size()); - } - - @Test - public void ttlSweepEvictsLeakedEntryOnceExpiredButNotBefore() { - AtomicLong nanos = new AtomicLong(0L); - IcebergRewritableDeleteStash stash = stashWithClock(300L, nanos); - - // q1 scanned but never writes (a leaked plain-SELECT entry). - stash.accumulate("q1", "s3://b/db/t/f1.parquet", descs("dv1", 3)); - Assertions.assertEquals(1, stash.size()); - - // 299s later: a new query arrives; q1 is still within TTL so it is NOT swept (it could still be a live - // supply whose write is pending). MUTATION: sweeping a live entry here would resurrect its rows. - nanos.set(TimeUnit.SECONDS.toNanos(299L)); - stash.accumulate("q2", "s3://b/db/t/f2.parquet", descs("dv2", 3)); - Assertions.assertEquals(2, stash.size()); - - // Past the TTL: the next new-query accumulate sweeps the now-expired q1 (but keeps the fresh q3). - nanos.set(TimeUnit.SECONDS.toNanos(301L)); - stash.accumulate("q3", "s3://b/db/t/f3.parquet", descs("dv3", 3)); - Assertions.assertNull(stash.retrieveAndRemove("q1")); - Assertions.assertNotNull(stash.retrieveAndRemove("q2")); - Assertions.assertNotNull(stash.retrieveAndRemove("q3")); - } - - @Test - public void accumulateRefreshesTouchStampSoAnActiveQueryIsNotSwept() { - AtomicLong nanos = new AtomicLong(0L); - IcebergRewritableDeleteStash stash = stashWithClock(300L, nanos); - - stash.accumulate("q1", "s3://b/db/t/f1.parquet", descs("dv1", 3)); - // q1 keeps adding ranges over time (a long scan). At 200s a fresh range refreshes its stamp. - nanos.set(TimeUnit.SECONDS.toNanos(200L)); - stash.accumulate("q1", "s3://b/db/t/f2.parquet", descs("dv2", 3)); - // 400s absolute = 200s since the last touch: still within TTL, must survive a sweep triggered by q9. - nanos.set(TimeUnit.SECONDS.toNanos(400L)); - stash.accumulate("q9", "s3://b/db/t/g.parquet", descs("dvg", 3)); - - Map> sets = stash.retrieveAndRemove("q1"); - Assertions.assertNotNull(sets); - Assertions.assertEquals(2, sets.size()); - } - - @Test - public void multipleDescsForOneDataFileAreAllCarried() { - // A v2->v3 upgraded data file can have BOTH an old position-delete file and an old DV; both must reach - // the BE for the union, or the rows covered by the missing one resurrect. - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("q1", "s3://b/db/t/f.parquet", - Arrays.asList(desc("pos.parquet", 1), desc("dv.puffin", 3))); - - Map> sets = stash.retrieveAndRemove("q1"); - Assertions.assertEquals(2, sets.get("s3://b/db/t/f.parquet").size()); - } -} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java index 489ed5c09e5114..b10d13f0788d84 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; @@ -83,6 +84,7 @@ import java.util.NoSuchElementException; import java.util.Optional; import java.util.Set; +import java.util.function.UnaryOperator; /** * Tests for {@link IcebergScanPlanProvider}. T01 pinned the capability constants + that {@code planScan} @@ -228,6 +230,49 @@ public void planScanResolvesTableInsideAuthContext() { Assertions.assertEquals("db1", ops.lastLoadDb); } + @Test + public void planningPassLoadsSameTableOnceViaSharedScope() { + // PERF-07 metric gate: a statement's metadata read (getColumnHandles) and scan planning (planScan) resolve + // the SAME table. Sharing ONE per-statement scope across both (same session) collapses BOTH remote reads + // onto a single loadTable RPC. This is the deterministic core claim — one remote loadTable per table per + // statement, independent of the cross-query cache (off here: disabled metadata cache, null-cache provider). + // MUTATION: not routing through the scope (each resolve re-loads) -> 2 loadTable log entries -> red. + Table empty = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + RecordingIcebergCatalogOps ops = opsReturning(empty); + IcebergConnectorMetadata metadata = + new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + ConnectorSession session = new FakeScanSession("UTC", Collections.emptyMap()) + .withScope(new TestStatementScope()); + + metadata.getColumnHandles(session, handle); + provider.planScan(session, handle, Collections.emptyList(), Optional.empty()); + + long remoteLoads = ops.log.stream().filter("loadTable:db1.t1"::equals).count(); + Assertions.assertEquals(1, remoteLoads, + "the per-statement scope must collapse the metadata + provider reads to one remote loadTable"); + } + + @Test + public void planningPassWithoutSharedScopeLoadsEachTime() { + // Contrast to the shared-scope gate: with NONE (no live statement scope) each resolver loads independently + // (byte-identical to the pre-scope offline behavior). MUTATION: memoizing under NONE -> 1 load -> red. + Table empty = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + RecordingIcebergCatalogOps ops = opsReturning(empty); + IcebergConnectorMetadata metadata = + new IcebergConnectorMetadata(ops, Collections.emptyMap(), new RecordingConnectorContext()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + ConnectorSession none = new FakeScanSession("UTC", Collections.emptyMap()); + + metadata.getColumnHandles(none, handle); + provider.planScan(none, handle, Collections.emptyList(), Optional.empty()); + + long remoteLoads = ops.log.stream().filter("loadTable:db1.t1"::equals).count(); + Assertions.assertEquals(2, remoteLoads, "under NONE each resolver loads (no memo)"); + } + // --- T02 split-enumeration + predicate-pushdown tests --- @Test @@ -337,6 +382,78 @@ public void planScanSplitsLargeFileByFileSplitSizeSessionVar() { Assertions.assertEquals(96 * mb, totalLength, "the split ranges must cover the whole file exactly"); } + @Test + public void planScanMemoizesPerFileInvariantsAcrossByteSlices() { + // PERF-11 (C12/C15a): a data file split into k byte-slices must compute its per-file invariants + // (partition JSON, ordered partition values, delete carriers, format, normalized path) exactly ONCE, + // not once per slice — while each slice keeps its own byte start/length. MUTATION: recomputing per slice + // (dropping the PerFileScratch reuse) -> perFileInvariantComputeCount == k -> red; memoizing start/length + // into the scratch -> the contiguous-tiling assertion -> red. + long mb = 1024L * 1024L; + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "s3://b/db/pt/p=7/big.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), "p=7")) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", + Collections.singletonMap("file_split_size", Long.toString(32 * mb))); + + List ranges = provider.planScan( + session, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + + Assertions.assertTrue(ranges.size() > 1, "expected the 96MB file to split, got " + ranges.size()); + // The per-file invariants were computed ONCE for the whole file — the memo gate. + Assertions.assertEquals(1, provider.perFileInvariantComputeCount, + "per-file invariants must be computed once per file, not once per byte-slice"); + // Every slice carries byte-identical per-file params (partition values + JSON) but its own tiling range. + long expectedStart = 0; + for (ConnectorScanRange r : ranges) { + Assertions.assertEquals(expectedStart, r.getStart(), "slices must tile contiguously from 0"); + Assertions.assertEquals(Collections.singletonMap("p", "7"), r.getPartitionValues(), + "every slice of the file carries the same identity partition values"); + Assertions.assertEquals("[\"7\"]", + populate(r).getTableFormatParams().getIcebergParams().getPartitionDataJson(), + "every slice carries the same partition_data_json"); + expectedStart += r.getLength(); + } + Assertions.assertEquals(96 * mb, expectedStart, "the split ranges must cover the whole file exactly"); + } + + @Test + public void planScanComputesPerFileInvariantsOncePerDistinctFile() { + // The memo recomputes on each file change and never returns a stale file's invariants: two split files + // -> perFileInvariantComputeCount == 2 (once per DISTINCT data file), regardless of total slice count, + // and each file's slices carry that file's OWN partition values. + long mb = 1024L * 1024L; + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "s3://b/db/pt/p=1/a.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), "p=1")) + .appendFile(dataFile(spec, "s3://b/db/pt/p=2/b.parquet", 96 * mb, + Arrays.asList(0L, 32 * mb, 64 * mb), "p=2")) + .commit(); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", + Collections.singletonMap("file_split_size", Long.toString(32 * mb))); + + List ranges = provider.planScan( + session, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + + Assertions.assertTrue(ranges.size() >= 4, + "two 96MB files at 32MB splits -> >=4 slices, got " + ranges.size()); + Assertions.assertEquals(2, provider.perFileInvariantComputeCount, + "per-file invariants must be computed once per distinct data file, not per slice"); + Assertions.assertEquals(Collections.singletonMap("p", "1"), + byPath(ranges, "p=1/a.parquet").getPartitionValues(), + "file a's slices carry p=1 (no cross-file staleness)"); + Assertions.assertEquals(Collections.singletonMap("p", "2"), + byPath(ranges, "p=2/b.parquet").getPartitionValues(), + "file b's slices carry p=2 (no cross-file staleness)"); + } + // ── M-2: size-proportional BE scheduling weight (selfSplitWeight / targetSplitSize) ── @Test @@ -1161,10 +1278,11 @@ public void planScanReadsRealFormatVersionAndEmitsV3RowLineage() { // ── commit-bridge supply (S4 part 2): a v3 scan stashes each data file's non-equality deletes by raw path ── @Test - public void planScanStashesRewritableDeletesKeyedByRawDataFilePathForV3() { - // A v3 scan over a data file that already has a deletion vector must stash that DV keyed on the data - // file's RAW path, so a same-statement DELETE/MERGE write can hand it to the BE. MUTATION: not stashing - // (or keying on the normalized path) -> the write supplies nothing -> the BE resurrects the deleted rows. + public void planScanAccumulatesRewritableDeletesKeyedByRawDataFilePathForV3() { + // A v3 scan over a data file that already has a deletion vector must accumulate that DV into the + // per-statement scope keyed on the data file's RAW path, so a same-statement DELETE/MERGE write can hand + // it to the BE. MUTATION: not accumulating (or keying on the normalized path) -> the write supplies + // nothing -> the BE resurrects the deleted rows. Map v3 = new HashMap<>(); v3.put("format-version", "3"); Table table = createTable("v3dv", SCHEMA, PartitionSpec.unpartitioned(), v3); @@ -1175,27 +1293,26 @@ public void planScanStashesRewritableDeletesKeyedByRawDataFilePathForV3() { .addDeletes(deletionVectorFile("s3://b/db/t1/dv.puffin", 16L, 64L)) .commit(); - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - IcebergScanPlanProvider provider = - new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), null, null, stash); - provider.planScan(new FakeScanSession("UTC", Collections.emptyMap()), - new IcebergTableHandle("db1", "v3dv"), Collections.emptyList(), Optional.empty()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", Collections.emptyMap()) + .withScope(new TestStatementScope()); + provider.planScan(session, new IcebergTableHandle("db1", "v3dv"), Collections.emptyList(), Optional.empty()); - Map> sets = stash.retrieveAndRemove("q"); - Assertions.assertNotNull(sets, "a v3 scan with a live DV must stash a supply for queryId 'q'"); + Map> sets = IcebergStatementScope.rewritableDeleteSupply(session); + Assertions.assertFalse(sets.isEmpty(), "a v3 scan with a live DV must accumulate a supply into the scope"); // Keyed on the RAW data-file path (== originalPath), the string the BE matches a rewritable set against. Assertions.assertTrue(sets.containsKey("s3://b/db/t1/f1.parquet"), - "stash must key on the raw data-file path, got keys: " + sets.keySet()); + "supply must key on the raw data-file path, got keys: " + sets.keySet()); List descs = sets.get("s3://b/db/t1/f1.parquet"); Assertions.assertEquals(1, descs.size()); Assertions.assertEquals(3, descs.get(0).getContent(), "the DV is content 3"); } @Test - public void planScanDoesNotStashForVersionTwo() { + public void planScanDoesNotAccumulateForVersionTwo() { // v2 deletes are plain position-delete files (no DV union); the rewritable supply is a v3-only concept. // A real position delete is committed so the assertion proves the formatVersion>=3 GATE, not an absence - // of deletes. MUTATION: dropping the v3 gate -> this v2 position delete would be stashed -> red. + // of deletes. MUTATION: dropping the v3 gate -> this v2 position delete would be accumulated -> red. Table table = createTable("v2pd", SCHEMA, PartitionSpec.unpartitioned(), Collections.singletonMap("format-version", "2")); table.newAppend() @@ -1205,18 +1322,19 @@ public void planScanDoesNotStashForVersionTwo() { .addDeletes(positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, null, null)) .commit(); - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - IcebergScanPlanProvider provider = - new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), null, null, stash); - provider.planScan(new FakeScanSession("UTC", Collections.emptyMap()), - new IcebergTableHandle("db1", "v2pd"), Collections.emptyList(), Optional.empty()); + IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); + ConnectorSession session = new FakeScanSession("UTC", Collections.emptyMap()) + .withScope(new TestStatementScope()); + provider.planScan(session, new IcebergTableHandle("db1", "v2pd"), Collections.emptyList(), Optional.empty()); - Assertions.assertEquals(0, stash.size(), "a v2 scan must not stash any rewritable supply"); + Assertions.assertTrue(IcebergStatementScope.rewritableDeleteSupply(session).isEmpty(), + "a v2 scan must not accumulate any rewritable supply"); } @Test - public void planScanWithoutStashIsInert() { - // The offline 2-arg ctor leaves the stash null; a v3 scan must not NPE — it simply skips stashing. + public void planScanUnderNoneScopeIsInert() { + // Under a NONE scope (offline / no live statement) a v3 scan must not NPE — it simply accumulates into a + // throwaway map that does not bridge to any write. MUTATION: dereferencing a missing scope -> NPE -> red. Map v3 = new HashMap<>(); v3.put("format-version", "3"); Table table = createTable("v3ns", SCHEMA, PartitionSpec.unpartitioned(), v3); @@ -1425,12 +1543,24 @@ public void streamSplitsCloseBeforeIterationDoesNotThrow() throws IOException { private static final class FakeScanSession implements ConnectorSession { private final String timeZone; private final Map sessionProperties; + private ConnectorStatementScope statementScope = ConnectorStatementScope.NONE; FakeScanSession(String timeZone, Map sessionProperties) { this.timeZone = timeZone; this.sessionProperties = sessionProperties; } + /** Installs a memoizing per-statement scope; share one instance across sessions to mimic one statement. */ + FakeScanSession withScope(ConnectorStatementScope scope) { + this.statementScope = scope; + return this; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return statementScope; + } + @Override public String getQueryId() { return "q"; @@ -1536,7 +1666,7 @@ public void convertDeletePositionDeleteCarriesBoundsAndFormat() { // POSITION_DELETES (non-PUFFIN) -> content 1, parquet/orc format, [lower,upper] bounds decoded from the // delete file's DELETE_FILE_POS bounds. MUTATION: wrong content id / dropped bounds / wrong format -> red. DeleteFile delete = positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, 3L, 17L); - TIcebergDeleteFileDesc d = provider().convertDelete(delete, Collections.emptyMap()).toThrift(); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); Assertions.assertEquals(1, d.getContent()); Assertions.assertEquals("s3://b/db/t1/pos.parquet", d.getPath()); @@ -1552,7 +1682,7 @@ public void convertDeletePositionDeleteWithoutBoundsLeavesThemUnset() { // No DELETE_FILE_POS bounds present -> position_lower/upper_bound stay unset (legacy emits them only // when present; it stores a -1 sentinel and skips emission). MUTATION: emitting 0/-1 -> red. DeleteFile delete = positionDeleteFile("s3://b/db/t1/pos.orc", FileFormat.ORC, null, null); - TIcebergDeleteFileDesc d = provider().convertDelete(delete, Collections.emptyMap()).toThrift(); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); Assertions.assertEquals(TFileFormatType.FORMAT_ORC, d.getFileFormat()); Assertions.assertFalse(d.isSetPositionLowerBound()); @@ -1565,7 +1695,7 @@ public void convertDeleteDeletionVectorCarriesBlobRefAndUnsetsFormat() { // (legacy setDeleteFileFormat skips PUFFIN). MUTATION: classifying it as content 1 / emitting a format // for the puffin blob -> red (BE would mis-read the DV blob). DeleteFile delete = deletionVectorFile("s3://b/db/t1/dv.puffin", 16L, 64L); - TIcebergDeleteFileDesc d = provider().convertDelete(delete, Collections.emptyMap()).toThrift(); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); Assertions.assertEquals(3, d.getContent()); Assertions.assertFalse(d.isSetFileFormat()); @@ -1629,7 +1759,7 @@ public void convertDeleteRejectsMalformedDeletionVector() { // to BE. MUTATION: removing the convertDelete call site -> this returns a carrier instead of throwing. DeleteFile delete = deletionVectorFile("s3://b/db/t1/dv.puffin", 200L, 100L); IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, () -> - provider().convertDelete(delete, Collections.emptyMap())); + provider().convertDelete(delete, UnaryOperator.identity())); Assertions.assertTrue(e.getMessage().contains("exceeds file size")); Assertions.assertTrue(e.getMessage().contains("dv.puffin")); } @@ -1640,7 +1770,7 @@ public void convertDeleteEqualityDeleteCarriesFieldIds() { // the T06 data-schema dictionary). MUTATION: wrong content id / dropped field-ids -> red (BE projects // the wrong columns for the equality match). DeleteFile delete = equalityDeleteFile("s3://b/db/t1/eq.parquet", FileFormat.PARQUET, 1, 2); - TIcebergDeleteFileDesc d = provider().convertDelete(delete, Collections.emptyMap()).toThrift(); + TIcebergDeleteFileDesc d = provider().convertDelete(delete, UnaryOperator.identity()).toThrift(); Assertions.assertEquals(2, d.getContent()); Assertions.assertEquals(Arrays.asList(1, 2), d.getFieldIds()); @@ -1660,7 +1790,8 @@ public void convertDeleteNormalizesDeletePathViaContext() { new IcebergScanPlanProvider(Collections.emptyMap(), new RecordingIcebergCatalogOps(), context); DeleteFile delete = positionDeleteFile("oss://bucket/db/t1/pos.parquet", FileFormat.PARQUET, null, null); - TIcebergDeleteFileDesc d = provider.convertDelete(delete, Collections.emptyMap()).toThrift(); + TIcebergDeleteFileDesc d = + provider.convertDelete(delete, provider.newUriNormalizer(Collections.emptyMap())).toThrift(); Assertions.assertEquals("s3://bucket/db/t1/pos.parquet", d.getPath()); Assertions.assertTrue(context.normalizedUris.contains("oss://bucket/db/t1/pos.parquet")); @@ -1760,14 +1891,15 @@ public void planScanNormalizesDataFilePathButKeepsOriginalFilePathRaw() { // --- T05: COUNT(*) pushdown (getCountFromSnapshot + collapse-to-one count range, mirrors paimon) --- - private static final IcebergTableHandle T1 = new IcebergTableHandle("db1", "t1"); - private static List planCount(IcebergScanPlanProvider provider, ConnectorSession session, boolean countPushdown) { // The COUNT-pushdown-aware 7-arg overload the generic PluginDrivenScanNode invokes (limit/ - // requiredPartitions are unused by the iceberg read path). - return provider.planScan(session, T1, Collections.emptyList(), Optional.empty(), - -1L, Collections.emptyList(), countPushdown); + // requiredPartitions are unused by the iceberg read path). A FRESH handle per call mirrors a real + // planning pass: PluginDrivenScanNode.currentHandle is a per-query, per-scan-node handle, so the + // query-scoped fat-handle memo (PERF-01) must never bleed from one test's table to another's (a shared + // static handle would serve the first scenario's cached table to every later one). + return provider.planScan(session, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), + Optional.empty(), -1L, Collections.emptyList(), countPushdown); } @Test @@ -1984,6 +2116,120 @@ public void planScanManifestCachePrunesPartitionLikeSdk() { Assertions.assertTrue(manifest.get(0).getPath().get().endsWith("p1.parquet")); } + // --- PERF-04: streaming (C17) + COUNT(*) (C18) paths read through the manifest cache, LAZILY --- + // (fallback-to-SDK on a cache-read failure is not unit-tested: IcebergManifestCache is final so it cannot be + // made to throw, exactly as the pre-existing synchronous planFileScanTask fallback is untested; the streaming/ + // count catch(Exception)+recordFailure mirrors that path verbatim.) + + @Test + public void streamSplitsManifestCacheEnabledMatchesSdkPathAndConsumesCache() throws IOException { + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/a.parquet", 100, null, null)) + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/b.parquet", 200, null, null)) + .appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/c.parquet", 300, null, null)) + .commit(); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + + // Cache OFF: the streaming SDK planFiles() path (the pre-PERF-04 behavior). + List sdk = drain(new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) + .streamSplits(emptySession(), handle, Collections.emptyList(), Optional.empty(), -1L)); + // Cache ON: streaming must now read manifests THROUGH the cache and yield the SAME files (PERF-04 C17). + // MUTATION: streaming still bypasses the cache (scan.planFiles()) -> cache stays empty -> red. + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = drain(manifestProvider(manifestCacheProps(), table, cache) + .streamSplits(emptySession(), handle, Collections.emptyList(), Optional.empty(), -1L)); + + Assertions.assertEquals(sortedPaths(sdk), sortedPaths(cached)); + Assertions.assertEquals(3, cached.size()); + Assertions.assertTrue(cache.size() > 0, "the streaming path must populate the manifest cache"); + } + + @Test + public void streamSplitsManifestCachePrunesPartitionLikeSdk() throws IOException { + PartitionSpec spec = PartitionSpec.builderFor(PART_SCHEMA).identity("p").build(); + Table table = createTable("pt", PART_SCHEMA, spec); + table.newAppend() + .appendFile(dataFile(spec, "/d/p1.parquet", 100, null, "p=1")) + .appendFile(dataFile(spec, "/d/p2.parquet", 100, null, "p=2")) + .commit(); + IcebergTableHandle handle = new IcebergTableHandle("db1", "pt"); + Optional wherePeq1 = Optional.of(eqInt("p", 1)); + + List sdk = drain(new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) + .streamSplits(emptySession(), handle, Collections.emptyList(), wherePeq1, -1L)); + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = drain(manifestProvider(manifestCacheProps(), table, cache) + .streamSplits(emptySession(), handle, Collections.emptyList(), wherePeq1, -1L)); + + // Partition prune (ManifestEvaluator + residual) keeps only p=1 in BOTH paths. MUTATION: the lazy iterator + // dropping the residual/metrics prune -> p=2 leaks in -> sizes differ -> red. + Assertions.assertEquals(sortedPaths(sdk), sortedPaths(cached)); + Assertions.assertEquals(1, cached.size()); + Assertions.assertTrue(cached.get(0).getPath().get().endsWith("p1.parquet")); + } + + @Test + public void streamSplitsManifestCacheFlatMapsAcrossDataManifests() throws IOException { + // Three separate appends -> three data manifests. The lazy flat-map iterator must walk ALL of them (not + // just the first) and yield every file. MUTATION: advance() not advancing past the first manifest -> < 3. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/a.parquet", 100, null, null)) + .commit(); + table.newAppend().appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/b.parquet", 200, null, null)) + .commit(); + table.newAppend().appendFile(dataFile(PartitionSpec.unpartitioned(), "/d/c.parquet", 300, null, null)) + .commit(); + Assertions.assertTrue(table.currentSnapshot().dataManifests(table.io()).size() >= 2, + "precondition: multiple data manifests"); + IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); + + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = drain(manifestProvider(manifestCacheProps(), table, cache) + .streamSplits(emptySession(), handle, Collections.emptyList(), Optional.empty(), -1L)); + Assertions.assertEquals(3, cached.size(), "the lazy iterator must flat-map across all data manifests"); + } + + @Test + public void countPushdownManifestCacheMatchesCountAndReadsLazily() { + // Three appends -> three data manifests; record counts 10+20+30 = total-records 60 (snapshot summary). + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2048, null, null)).commit(); + table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f3.parquet", 3072, null, null)).commit(); + int totalManifests = table.currentSnapshot().dataManifests(table.io()).size(); + Assertions.assertTrue(totalManifests >= 2, "precondition: multiple data manifests"); + + List sdk = planCount( + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)), null, true); + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = planCount(manifestProvider(manifestCacheProps(), table, cache), + emptySession(), true); + + // Same collapsed single range + same count (from the snapshot summary). The placeholder file path may + // differ (SDK planFiles' ParallelIterable order is non-deterministic), so assert count + shape, not path. + Assertions.assertEquals(1, sdk.size()); + Assertions.assertEquals(1, cached.size()); + Assertions.assertEquals(60L, cached.get(0).getPushDownRowCount()); + Assertions.assertEquals(sdk.get(0).getPushDownRowCount(), cached.get(0).getPushDownRowCount()); + // Lazy early stop: COUNT needs only the first surviving file, so it must NOT read every data manifest. + // MUTATION: routing count through the materialized cache path -> reads all manifests -> size == total -> red. + Assertions.assertTrue(cache.size() >= 1 && cache.size() < totalManifests, + "count reads lazily (stops at the first file's manifest), not the whole table"); + } + + @Test + public void countPushdownManifestCacheEmptyNullSnapshotReturnsNoRanges() { + // A never-appended table has no current snapshot; getCountFromSnapshot returns 0 (>=0) so planCountPushdown + // runs with a null-snapshot scan. cacheBackedFileScanTasks must keep the null-snapshot guard (empty + // iterable), not NPE. MUTATION: dropping the guard -> NPE on scan.snapshot() -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + IcebergManifestCache cache = new IcebergManifestCache(); + List cached = planCount(manifestProvider(manifestCacheProps(), table, cache), + emptySession(), true); + Assertions.assertTrue(cached.isEmpty(), "empty (null-snapshot) count with cache enabled yields no ranges"); + } + @Test public void planScanManifestCacheEmptyTableReturnsNoRanges() { Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); @@ -2272,6 +2518,32 @@ public void planScanThreadsVendedTokenIntoDataAndDeletePathNormalize() { Assertions.assertEquals(IcebergScanPlanProvider.extractVendedToken(table, false), context.lastVendedToken); } + @Test + public void planScanDerivesUriNormalizerOncePerScanNotPerFile() { + // C3 PERF GUARD: the vended token is scan-invariant, so the expensive token->storage-config + // derivation must be built ONCE per scan and reused for every file path — not rebuilt per data file. + // Drive a scan over three data files and assert the connector entered newStorageUriNormalizer exactly + // once while still normalizing all three paths. MUTATION: reverting to a per-file + // context.normalizeStorageUri (re-deriving the config per file) leaves newNormalizerCount == 0 (the + // once-per-scan seam is never used) -> red; dropping a path's normalize -> normalizeCount != 3 -> red. + Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); + table.newAppend() + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f1.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f2.parquet", 1024, null, null)) + .appendFile(dataFile(table.spec(), "oss://b/db/t1/f3.parquet", 1024, null, null)) + .commit(); + RecordingConnectorContext context = new RecordingConnectorContext(); + IcebergScanPlanProvider provider = + new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); + + List ranges = provider.planScan( + null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + + Assertions.assertEquals(3, ranges.size()); + Assertions.assertEquals(1, context.newNormalizerCount); + Assertions.assertEquals(3, context.normalizeCount); + } + @Test public void convertDeleteNormalizesDeletePathViaVendedToken() { RecordingConnectorContext context = new RecordingConnectorContext(); @@ -2280,10 +2552,10 @@ public void convertDeleteNormalizesDeletePathViaVendedToken() { DeleteFile delete = positionDeleteFile("oss://bucket/db/t1/pos.parquet", FileFormat.PARQUET, null, null); Map token = Collections.singletonMap("s3.access-key-id", "ak"); - TIcebergDeleteFileDesc d = provider.convertDelete(delete, token).toThrift(); + TIcebergDeleteFileDesc d = provider.convertDelete(delete, provider.newUriNormalizer(token)).toThrift(); - // WHY: convertDelete must thread the vended token into the 2-arg normalize (T09). MUTATION: passing no - // token / the 1-arg normalize -> lastVendedToken != token -> red. + // WHY: the scan-scoped normalizer must bake in the vended token so the delete path normalizes via the + // 2-arg seam (T09). MUTATION: dropping the token / the 1-arg normalize -> lastVendedToken != token -> red. Assertions.assertEquals("s3://bucket/db/t1/pos.parquet", d.getPath()); Assertions.assertEquals(token, context.lastVendedToken); } @@ -2371,14 +2643,14 @@ public void convertDeletePositionDeleteTreatsStoredMinusOneBoundAsUnset() { // sentinel. The existing no-bounds test passes a null map (early return), never reaching the value==-1L // arm. MUTATION: dropping the `|| value == -1L` arm (emitting -1 as a real bound) -> red. DeleteFile bothMinusOne = positionDeleteFile("s3://b/db/t1/pos.parquet", FileFormat.PARQUET, -1L, -1L); - TIcebergDeleteFileDesc d = provider().convertDelete(bothMinusOne, Collections.emptyMap()).toThrift(); + TIcebergDeleteFileDesc d = provider().convertDelete(bothMinusOne, UnaryOperator.identity()).toThrift(); Assertions.assertEquals(1, d.getContent()); Assertions.assertFalse(d.isSetPositionLowerBound()); Assertions.assertFalse(d.isSetPositionUpperBound()); // Mixed: only the -1L bound is dropped; a real lower bound still emits. DeleteFile mixed = positionDeleteFile("s3://b/db/t1/pos2.parquet", FileFormat.PARQUET, 3L, -1L); - TIcebergDeleteFileDesc m = provider().convertDelete(mixed, Collections.emptyMap()).toThrift(); + TIcebergDeleteFileDesc m = provider().convertDelete(mixed, UnaryOperator.identity()).toThrift(); Assertions.assertEquals(3L, m.getPositionLowerBound()); Assertions.assertFalse(m.isSetPositionUpperBound()); } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java new file mode 100644 index 00000000000000..70238ea323f82d --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergStatementScopeTest.java @@ -0,0 +1,179 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.thrift.TIcebergDeleteFileDesc; + +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tests for {@link IcebergStatementScope}: the per-statement table memo keying (catalog id + db + table + + * queryId) that lets a statement's read/scan/write share ONE loaded table, and the rewritable-delete supply + * map keying (catalog id + queryId) that bridges the scan→write seam. + */ +public class IcebergStatementScopeTest { + + private static final Schema SCHEMA = + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + + private static Table table(String name) { + return new FakeIcebergTable(name, SCHEMA, PartitionSpec.unpartitioned(), + "s3://b/db1/" + name, Collections.emptyMap()); + } + + @Test + public void sameStatementSharesOneLoadedTable() { + // The read, scan and write resolvers of one statement resolve the same table through sharedTable; sharing + // one scope collapses them onto one load and hands each the SAME instance (read/write share). + // MUTATION: not memoizing -> two loads / two instances -> red. + ScopeSession session = new ScopeSession(7L, "q1", new TestStatementScope()); + AtomicInteger loads = new AtomicInteger(); + Table t1 = IcebergStatementScope.sharedTable(session, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + Table t2 = IcebergStatementScope.sharedTable(session, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + Assertions.assertSame(t1, t2, "same statement + table -> one shared instance"); + Assertions.assertEquals(1, loads.get(), "loaded once per statement"); + } + + @Test + public void differentQueryIdIsolatesTheLoad() { + // A reused prepared statement runs each EXECUTE under its own queryId, so one execution never sees + // another's table even on the same scope object. + TestStatementScope scope = new TestStatementScope(); + Table a = IcebergStatementScope.sharedTable(new ScopeSession(7L, "q1", scope), "db1", "t", () -> table("t")); + Table b = IcebergStatementScope.sharedTable(new ScopeSession(7L, "q2", scope), "db1", "t", () -> table("t")); + Assertions.assertNotSame(a, b, "different queryId -> isolated load"); + } + + @Test + public void differentCatalogIdIsolatesTheLoad() { + // A cross-catalog MERGE resolves the two catalogs' tables independently (the key carries the catalog id). + TestStatementScope scope = new TestStatementScope(); + Table a = IcebergStatementScope.sharedTable(new ScopeSession(1L, "q1", scope), "db1", "t", () -> table("t")); + Table b = IcebergStatementScope.sharedTable(new ScopeSession(2L, "q1", scope), "db1", "t", () -> table("t")); + Assertions.assertNotSame(a, b, "different catalog id -> isolated load"); + } + + @Test + public void underNoneScopeLoadsEveryTime() { + // No live statement scope: each call loads (byte-identical to the pre-scope offline behavior). + ScopeSession none = new ScopeSession(7L, "q1", ConnectorStatementScope.NONE); + AtomicInteger loads = new AtomicInteger(); + IcebergStatementScope.sharedTable(none, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + IcebergStatementScope.sharedTable(none, "db1", "t", () -> { + loads.incrementAndGet(); + return table("t"); + }); + Assertions.assertEquals(2, loads.get(), "NONE -> load every time"); + } + + @Test + public void rewritableDeleteSupplyIsSharedPerStatementAndIsolatedPerCatalog() { + // The scan seam and the write seam of one statement (same catalog + queryId) share ONE supply map; a + // cross-catalog MERGE keeps each catalog's supply isolated. MUTATION: dropping the catalog id from the + // key -> the two catalogs collide -> red. + TestStatementScope scope = new TestStatementScope(); + Map> supplyScan = + IcebergStatementScope.rewritableDeleteSupply(new ScopeSession(1L, "q1", scope)); + Map> supplyWrite = + IcebergStatementScope.rewritableDeleteSupply(new ScopeSession(1L, "q1", scope)); + Assertions.assertSame(supplyScan, supplyWrite, "scan and write of one statement share one supply map"); + + Map> supplyOtherCatalog = + IcebergStatementScope.rewritableDeleteSupply(new ScopeSession(2L, "q1", scope)); + Assertions.assertNotSame(supplyScan, supplyOtherCatalog, "a different catalog (cross-catalog MERGE) is isolated"); + } + + /** Minimal {@link ConnectorSession} carrying a catalog id, queryId and scope for the key + memo assertions. */ + private static final class ScopeSession implements ConnectorSession { + private final long catalogId; + private final String queryId; + private final ConnectorStatementScope scope; + + ScopeSession(long catalogId, String queryId, ConnectorStatementScope scope) { + this.catalogId = catalogId; + this.queryId = queryId; + this.scope = scope; + } + + @Override + public long getCatalogId() { + return catalogId; + } + + @Override + public String getQueryId() { + return queryId; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return scope; + } + + @Override + public String getUser() { + return "u"; + } + + @Override + public String getTimeZone() { + return "UTC"; + } + + @Override + public String getLocale() { + return "en_US"; + } + + @Override + public String getCatalogName() { + return "c"; + } + + @Override + public T getProperty(String name, Class type) { + return null; + } + + @Override + public Map getCatalogProperties() { + return Collections.emptyMap(); + } + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java new file mode 100644 index 00000000000000..9dd551c35fb483 --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableCacheTest.java @@ -0,0 +1,185 @@ +// 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.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Unit tests for {@link IcebergTableCache} (PERF-01). The cross-query RAW-table cache mirrors + * {@link IcebergLatestSnapshotCache} exactly (same {@link org.apache.doris.connector.cache.MetaCacheEntry} + * backing) but stores the whole {@link Table} instead of the {@code (snapshotId, schemaId)} pin, restoring the + * table-caching half of the legacy {@code IcebergExternalMetaCache}. These tests cover the adapter's contract — + * within-TTL stability, the {@code ttl <= 0} disable, invalidation, and the exception-propagation guarantee the + * partition-view readers depend on. Timed-expiry mechanics are the framework's responsibility (unit-tested in + * the framework module), so they are not re-proven here. + */ +public class IcebergTableCacheTest { + + private static TableIdentifier id() { + return TableIdentifier.of("db", "t"); + } + + /** A distinct fake table, distinguishable by {@link Table#name()}. */ + private static Table table(String name) { + return new FakeIcebergTable(name, + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())), + PartitionSpec.unpartitioned(), "s3://b/" + name, Collections.emptyMap()); + } + + @Test + public void cachesWithinTtlAndServesTheSameTable() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(100, 1000); + + Table first = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + // Second read within TTL must return the CACHED table (first), NOT a freshly-loaded one -> this is what + // lets consecutive queries (and one query's analysis/planning phases) reuse a single load. MUTATION: + // loading live every call -> returns "second" / loads==2 -> red. + Table second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + Assertions.assertEquals("first", first.name()); + Assertions.assertSame(first, second, "within TTL the cached table instance must be served"); + Assertions.assertEquals(1, loads.get(), "the live loader must run exactly once within TTL"); + Assertions.assertTrue(c.isEnabled()); + } + + @Test + public void ttlZeroDisablesCachingAlwaysLive() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(0, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + Table second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + // ttl-second=0 (the no-cache catalog) reads live every time. MUTATION: caching despite ttl<=0 -> + // second=="first" / loads==1 -> red. + Assertions.assertEquals("second", second.name(), "ttl-second=0 must always read the live table"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + Assertions.assertEquals(0, c.size(), "ttl-second=0 must not store anything"); + } + + @Test + public void negativeTtlDisablesCachingAlwaysLive() { + // ttl-second=-1 (or any negative) is still the no-cache catalog. Guards the CacheSpec trap where + // ttl == -1 means "no expiration (enabled)": the adapter must translate "<= 0" to disabled. + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(-1, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + Table second = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + Assertions.assertEquals("second", second.name(), "ttl-second=-1 must always read the live table"); + Assertions.assertEquals(2, loads.get()); + Assertions.assertFalse(c.isEnabled()); + } + + @Test + public void invalidateForcesReload() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(100, 1000); + c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("first"); + }); + c.invalidate(id()); + // After REFRESH TABLE invalidation the next read goes live. MUTATION: invalidate not clearing -> + // returns cached "first" / loads==1 -> red. + Table after = c.getOrLoad(id(), () -> { + loads.incrementAndGet(); + return table("second"); + }); + Assertions.assertEquals("second", after.name()); + Assertions.assertEquals(2, loads.get()); + } + + @Test + public void invalidateAllClearsEverything() { + IcebergTableCache c = new IcebergTableCache(100, 1000); + c.getOrLoad(TableIdentifier.of("db", "t1"), () -> table("t1")); + c.getOrLoad(TableIdentifier.of("db", "t2"), () -> table("t2")); + Assertions.assertEquals(2, c.size()); + c.invalidateAll(); + Assertions.assertEquals(0, c.size()); + } + + @Test + public void invalidateDbClearsOnlyThatDbsTables() { + AtomicInteger loads = new AtomicInteger(); + IcebergTableCache c = new IcebergTableCache(100, 1000); + c.getOrLoad(TableIdentifier.of("db1", "t1"), () -> table("db1.t1")); + c.getOrLoad(TableIdentifier.of("db1", "t2"), () -> table("db1.t2")); + c.getOrLoad(TableIdentifier.of("db2", "t1"), () -> table("db2.t1")); + Assertions.assertEquals(3, c.size()); + + // REFRESH DATABASE db1 (or a Doris DROP DATABASE db1) must drop BOTH db1 tables and leave db2 intact. + // MUTATION: invalidateDb a no-op -> db1.t1 still cached -> loads stays 0 / after=="db1.t1" -> red. + c.invalidateDb("db1"); + Assertions.assertEquals(1, c.size(), "only db2's single entry must survive"); + + Table afterDb1 = c.getOrLoad(TableIdentifier.of("db1", "t1"), () -> { + loads.incrementAndGet(); + return table("db1.t1.reloaded"); + }); + Assertions.assertEquals("db1.t1.reloaded", afterDb1.name(), "db1.t1 must reload live after invalidateDb"); + Assertions.assertEquals(1, loads.get()); + + Table db2 = c.getOrLoad(TableIdentifier.of("db2", "t1"), () -> { + loads.incrementAndGet(); + return table("db2.t1.reloaded"); + }); + Assertions.assertEquals("db2.t1", db2.name(), "db2 must keep its cached table (not dropped by invalidateDb(db1))"); + Assertions.assertEquals(1, loads.get(), "db2 read must be a hit (no extra load)"); + } + + @Test + public void loaderExceptionPropagatesUnwrapped() { + // The partition-view readers (listPartitions / listPartitionNames) catch NoSuchTableException to degrade + // a concurrent-drop race to an empty list. Routing them through this cache must NOT wrap that exception, + // or the degradation would break and they'd throw instead. The MetaCacheEntry manual-miss-load path + // re-throws the loader's RuntimeException verbatim. MUTATION: wrapping the loader exception -> + // assertThrows(NoSuchTableException) fails (a different type is thrown) -> red. + IcebergTableCache c = new IcebergTableCache(100, 1000); + Assertions.assertThrows(NoSuchTableException.class, () -> c.getOrLoad(id(), () -> { + throw new NoSuchTableException("simulated concurrent drop"); + })); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java index 2578f13806b925..f5e928afaaa50c 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergTableHandleTest.java @@ -354,4 +354,5 @@ public void topnLazyMaterializeComposesWithPinAndScope() { Assertions.assertEquals(42L, h.getSnapshotId()); Assertions.assertEquals(ImmutableSet.of("oss://b/db/t1/f1.parquet"), h.getRewriteFileScope()); } + } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java index 15126a6a2d163b..caf52891f480b4 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java @@ -19,6 +19,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; @@ -803,18 +804,17 @@ public void planWriteDeleteSinkFormatVersionThree() { "a v3 delete with no live delete files to rewrite must not set rewritable sets (legacy gates on non-empty)"); } - // ── commit-bridge supply (S4 part 2): planWrite drains the scan-time stash into rewritable_delete_file_sets ── + // ── commit-bridge supply (S4 part 2): planWrite drains the per-statement scope into rewritable_delete_file_sets ── private static final String STASH_QID = "qid-stash"; - private static IcebergWritePlanProvider providerWithStash(Table table, RecordingConnectorContext ctx, - IcebergRewritableDeleteStash stash) { + private static IcebergWritePlanProvider supplyProvider(Table table, RecordingConnectorContext ctx) { RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); ops.table = table; - return new IcebergWritePlanProvider(NON_REST_PROPS, ops, ctx, stash); + return new IcebergWritePlanProvider(NON_REST_PROPS, ops, ctx); } - private static WriteSession stashSession(Table table, RecordingConnectorContext ctx, String queryId) { + private static WriteSession supplySession(Table table, RecordingConnectorContext ctx, String queryId) { RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); ops.table = table; IcebergConnectorTransaction txn = new IcebergConnectorTransaction(42L, ops, ctx); @@ -831,18 +831,18 @@ private static List dvDescs(String path, long offset, lo } @Test - public void planWriteDeleteSinkAttachesRewritableSetsFromStashAndEvicts() { - // The scan stashed this statement's old DV (referenced data file -> its non-equality deletes); planWrite - // must drain it onto the sink so the BE OR-merges those deletes into the new DV. MUTATION: not reading - // the stash / not setting the field -> the BE writes a DV without the old deletes -> resurrection. + public void planWriteDeleteSinkAttachesRewritableSetsFromScope() { + // The scan accumulated this statement's old DV (referenced data file -> its non-equality deletes) into the + // per-statement scope; planWrite must drain it onto the sink so the BE OR-merges those deletes into the + // new DV. MUTATION: not reading the scope / not setting the field -> the BE writes a DV without the old + // deletes -> resurrection. Table table = formatVersionThreeTable(freshCatalog()); RecordingConnectorContext ctx = contextWithStorage(); - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate(STASH_QID, "oss://bucket/wh/db1/tv3/data/f1.parquet", + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session).put("oss://bucket/wh/db1/tv3/data/f1.parquet", dvDescs("oss://bucket/wh/db1/tv3/data/dv1.puffin", 16L, 64L)); - ConnectorSinkPlan plan = providerWithStash(table, ctx, stash).planWrite( - stashSession(table, ctx, STASH_QID), + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.DELETE)); TIcebergDeleteSink sink = plan.getDataSink().getIcebergDeleteSink(); @@ -854,20 +854,17 @@ public void planWriteDeleteSinkAttachesRewritableSetsFromStashAndEvicts() { Assertions.assertEquals(1, set.getDeleteFilesSize()); Assertions.assertEquals("oss://bucket/wh/db1/tv3/data/dv1.puffin", set.getDeleteFiles().get(0).getPath()); Assertions.assertEquals(3, set.getDeleteFiles().get(0).getContent()); - // The retrieve is the primary eviction; a second statement must not re-read this entry. - Assertions.assertEquals(0, stash.size(), "planWrite must evict the stash entry it consumed"); } @Test - public void planWriteMergeSinkAttachesRewritableSetsFromStash() { + public void planWriteMergeSinkAttachesRewritableSetsFromScope() { Table table = formatVersionThreeTable(freshCatalog()); RecordingConnectorContext ctx = contextWithStorage(); - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate(STASH_QID, "oss://bucket/wh/db1/tv3/data/f1.parquet", + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session).put("oss://bucket/wh/db1/tv3/data/f1.parquet", dvDescs("oss://bucket/wh/db1/tv3/data/dv1.puffin", 8L, 32L)); - ConnectorSinkPlan plan = providerWithStash(table, ctx, stash).planWrite( - stashSession(table, ctx, STASH_QID), + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.MERGE)); TIcebergMergeSink sink = plan.getDataSink().getIcebergMergeSink(); @@ -875,63 +872,72 @@ public void planWriteMergeSinkAttachesRewritableSetsFromStash() { "a v3 MERGE must carry rewritable_delete_file_sets (thrift field 25) too"); Assertions.assertEquals("oss://bucket/wh/db1/tv3/data/f1.parquet", sink.getRewritableDeleteFileSets().get(0).getReferencedDataFilePath()); - Assertions.assertEquals(0, stash.size()); } @Test - public void planWriteDeleteSinkLeavesSetsUnsetWhenNoStashEntryForQuery() { - // A v3 DELETE whose scan stashed nothing for this queryId (no live deletes) leaves the field unset — - // byte-identical to legacy's empty gate. + public void planWriteDeleteSinkLeavesSetsUnsetWhenScopeHasNoSupply() { + // A v3 DELETE whose scan accumulated nothing into the scope (no live deletes) leaves the field unset — + // byte-identical to legacy's empty gate. The queryId scoping means a different statement's supply is + // simply not visible here. Table table = formatVersionThreeTable(freshCatalog()); RecordingConnectorContext ctx = contextWithStorage(); - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate("some-other-query", "oss://bucket/wh/db1/tv3/data/f1.parquet", - dvDescs("dv", 1L, 2L)); - ConnectorSinkPlan plan = providerWithStash(table, ctx, stash).planWrite( - stashSession(table, ctx, STASH_QID), + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite( + supplySession(table, ctx, STASH_QID), new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.DELETE)); Assertions.assertFalse(plan.getDataSink().getIcebergDeleteSink().isSetRewritableDeleteFileSets()); - // The other query's entry is untouched. - Assertions.assertEquals(1, stash.size()); } @Test - public void planWriteVersionTwoDeleteNeverAttachesRewritableSetsButStillEvicts() { - // v2 deletes are plain position-delete files (no DV union), so even a stashed supply must NOT be emitted - // (the BE ignores field 15 below v3). MUTATION: dropping the formatVersion>=3 gate would emit it. The - // retrieve still evicts (no leak). + public void planWriteVersionTwoDeleteNeverAttachesRewritableSets() { + // v2 deletes are plain position-delete files (no DV union), so even a scope-carried supply must NOT be + // emitted (the BE ignores field 15 below v3). MUTATION: dropping the formatVersion>=3 gate would emit it. InMemoryCatalog catalog = freshCatalog(); Table table = catalog.createTable(TableIdentifier.of("db1", "tv2"), SCHEMA, PartitionSpec.unpartitioned(), Collections.singletonMap("format-version", "2")); RecordingConnectorContext ctx = contextWithStorage(); - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate(STASH_QID, "oss://bucket/wh/db1/tv2/data/f1.parquet", dvDescs("dv", 1L, 2L)); + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session) + .put("oss://bucket/wh/db1/tv2/data/f1.parquet", dvDescs("dv", 1L, 2L)); - ConnectorSinkPlan plan = providerWithStash(table, ctx, stash).planWrite( - stashSession(table, ctx, STASH_QID), + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, new WriteHandle(new IcebergTableHandle("db1", "tv2")).writeOperation(WriteOperation.DELETE)); Assertions.assertFalse(plan.getDataSink().getIcebergDeleteSink().isSetRewritableDeleteFileSets()); - Assertions.assertEquals(0, stash.size(), "the retrieve evicts even when the v3 gate drops the supply"); } @Test - public void planWriteInsertEvictsStashEntryForTheStatement() { - // INSERT ... SELECT FROM an iceberg source stashes the source scan's deletes, but the INSERT write does - // not consume them; planWrite must still evict the entry so it does not leak. MUTATION: gating the - // retrieve on DELETE/MERGE only would leak the INSERT path's entry. + public void planWriteInsertIgnoresScopeSupply() { + // INSERT ... SELECT FROM an iceberg source accumulates the source scan's deletes into the scope, but the + // INSERT write does not consume them; planWrite must still produce a plain table sink (the supply is + // simply ignored, not attached). MUTATION: attaching the supply to an INSERT sink -> red. Table table = formatVersionThreeTable(freshCatalog()); RecordingConnectorContext ctx = contextWithStorage(); - IcebergRewritableDeleteStash stash = new IcebergRewritableDeleteStash(); - stash.accumulate(STASH_QID, "oss://bucket/wh/db1/tv3/data/src.parquet", dvDescs("dv", 1L, 2L)); + WriteSession session = supplySession(table, ctx, STASH_QID); + IcebergStatementScope.rewritableDeleteSupply(session) + .put("oss://bucket/wh/db1/tv3/data/src.parquet", dvDescs("dv", 1L, 2L)); - providerWithStash(table, ctx, stash).planWrite( - stashSession(table, ctx, STASH_QID), + ConnectorSinkPlan plan = supplyProvider(table, ctx).planWrite(session, new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.INSERT)); - Assertions.assertEquals(0, stash.size(), "an INSERT write still evicts its statement's stash entry"); + Assertions.assertTrue(plan.getDataSink().isSetIcebergTableSink(), "INSERT builds a plain table sink"); + } + + @Test + public void planWriteV3RowLevelDmlUnderNoneScopeFailsLoud() { + // A format-version>=3 DELETE/MERGE under NONE (no live statement scope) cannot have received the scan's + // rewritable-delete supply, so planWrite must reject it rather than silently write a DV that drops old + // deletes. MUTATION: dropping the fail-loud -> a v3 DELETE under NONE resurrects previously-deleted rows. + Table table = formatVersionThreeTable(freshCatalog()); + RecordingConnectorContext ctx = contextWithStorage(); + WriteSession session = supplySession(table, ctx, STASH_QID).noScope(); + + DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, () -> + supplyProvider(table, ctx).planWrite(session, + new WriteHandle(new IcebergTableHandle("db1", "tv3")).writeOperation(WriteOperation.DELETE))); + Assertions.assertTrue(ex.getMessage().contains("per-statement scope"), + "fail-loud message must name the missing per-statement scope, got: " + ex.getMessage()); } @Test @@ -1159,6 +1165,9 @@ public TSortInfo getSortInfo() { private static final class WriteSession implements ConnectorSession { private final ConnectorTransaction txn; private final String queryId; + // Default to a live memoizing scope so a v3 row-level DML does not trip planWrite's NONE fail-loud; the + // dedicated fail-loud test forces NONE via noScope(). + private ConnectorStatementScope statementScope = new TestStatementScope(); WriteSession(ConnectorTransaction txn) { this(txn, "q"); @@ -1169,6 +1178,23 @@ private static final class WriteSession implements ConnectorSession { this.queryId = queryId; } + /** Shares an explicit scope (so a test can pre-fill the rewritable-delete supply the write then reads). */ + WriteSession withScope(ConnectorStatementScope scope) { + this.statementScope = scope; + return this; + } + + /** Forces ConnectorStatementScope.NONE (for the v3-DML-under-NONE fail-loud test). */ + WriteSession noScope() { + this.statementScope = ConnectorStatementScope.NONE; + return this; + } + + @Override + public ConnectorStatementScope getStatementScope() { + return statementScope; + } + @Override public Optional getCurrentTransaction() { return Optional.ofNullable(txn); diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java index dd6fde1b974db8..2fedacfb299ee0 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWriterHelperTest.java @@ -23,6 +23,7 @@ import com.google.common.base.VerifyException; import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionSpec; @@ -245,4 +246,84 @@ public void getFileFormatThrowsOnUnsupported() { Assertions.assertThrows(RuntimeException.class, () -> IcebergWriterHelper.getFileFormat(tableWith("write.format.default", "avro"))); } + + // ─────────────────── getFileFormat: PERF-03 cross-query inference cache ─────────────────── + + /** Unpartitioned table carrying one data file of {@code fileFormat} and the given extra properties. */ + private Table tableWithDataFile(FileFormat fileFormat, String... props) { + Table table = tableWith(props); + table.newAppend().appendFile(DataFiles.builder(unpartitionedSpec) + .withPath("s3://b/db1/t/f0." + fileFormat.name().toLowerCase()) + .withFileSizeInBytes(100) + .withRecordCount(1) + .withFormat(fileFormat) + .build()).commit(); + return table; + } + + @Test + public void getFileFormatInferenceIsCachedAcrossQueriesAtSameSnapshot() { + // A table with NO write-format / write.format.default (migrated / engine-default table) resolves the scan + // level file_format_type via an unfiltered whole-table planFiles() inference (the #64134 heavy op). PERF-03 + // memoizes that inference per (table, currentSnapshotId): repeated getScanNodeProperties computes across + // queries collapse onto ONE remote inference. MUTATION: not threading the cache into the call -> each query + // re-scans -> loadCountForTest > 1 -> red. + Table table = tableWithDataFile(FileFormat.ORC); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergFormatCache cache = new IcebergFormatCache(100, 1000); + + FileFormat f1 = IcebergWriterHelper.getFileFormat(table, id, cache); + FileFormat f2 = IcebergWriterHelper.getFileFormat(table, id, cache); + FileFormat f3 = IcebergWriterHelper.getFileFormat(table, id, cache); + + Assertions.assertEquals(FileFormat.ORC, f1); + Assertions.assertEquals(FileFormat.ORC, f2); + Assertions.assertEquals(FileFormat.ORC, f3); + Assertions.assertEquals(1, cache.loadCountForTest(), + "the whole-table inference must run exactly once across repeated queries at one snapshot"); + Assertions.assertEquals(1, cache.size(), "one (table, snapshot) entry"); + // Parity: the cached resolution matches the uncached (live) resolution. + Assertions.assertEquals(IcebergWriterHelper.getFileFormat(table), f1, + "cached format must equal a live (uncached) inference"); + } + + @Test + public void getFileFormatPropertyPathIsNeverCached() { + // When the table carries a format property, resolution is a cheap property read that must NOT touch the + // cache (only the inference fallback is memoized). MUTATION: caching the property path -> size > 0 -> red. + Table table = tableWithDataFile(FileFormat.ORC, "write.format.default", "parquet"); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergFormatCache cache = new IcebergFormatCache(100, 1000); + + Assertions.assertEquals(FileFormat.PARQUET, IcebergWriterHelper.getFileFormat(table, id, cache)); + Assertions.assertEquals(0, cache.size(), "the property path must never populate the inference cache"); + Assertions.assertEquals(0, cache.loadCountForTest()); + } + + @Test + public void getFileFormatNullCacheResolvesLive() { + // Offline / pre-cache paths pass a null cache: resolution stays live and correct (no NPE). + Table table = tableWithDataFile(FileFormat.ORC); + Assertions.assertEquals(FileFormat.ORC, + IcebergWriterHelper.getFileFormat(table, TableIdentifier.of("db1", "t"), null)); + } + + @Test + public void getFileFormatUnsupportedFirstFileCachesInferenceButRethrowsEachCall() { + // R3: an unsupported first data-file format (e.g. avro) is a SUCCESSFUL inference (the loader returns + // "avro"); the unsupported-format throw happens in the format mapping OUTSIDE getOrLoad. So the inference is + // cached once (loadCount == 1) yet every call re-throws (parity with legacy, which inferred + threw every + // query) without re-scanning. MUTATION: caching the mapped value / caching the throw -> loadCount != 1. + Table table = tableWithDataFile(FileFormat.AVRO); + TableIdentifier id = TableIdentifier.of("db1", "t"); + IcebergFormatCache cache = new IcebergFormatCache(100, 1000); + + Assertions.assertThrows(RuntimeException.class, + () -> IcebergWriterHelper.getFileFormat(table, id, cache)); + Assertions.assertThrows(RuntimeException.class, + () -> IcebergWriterHelper.getFileFormat(table, id, cache)); + Assertions.assertEquals(1, cache.loadCountForTest(), + "the inference runs once; the unsupported-format throw is in the mapping, not the loader"); + Assertions.assertEquals(1, cache.size()); + } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingConnectorContext.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingConnectorContext.java index 8956a899630320..7876783a04d6c2 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingConnectorContext.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingConnectorContext.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.function.UnaryOperator; /** * Hand-written {@link ConnectorContext} test double (no Mockito), adapted verbatim from the paimon @@ -60,6 +61,9 @@ final class RecordingConnectorContext implements ConnectorContext { final List normalizedUris = new ArrayList<>(); /** Number of times the connector invoked {@link #normalizeStorageUri} (1- or 2-arg). */ int normalizeCount; + /** Number of times the connector built a scan-scoped normalizer via {@link #newStorageUriNormalizer} + * (should be once per scan — the perf hoist guard). */ + int newNormalizerCount; /** The vended token the connector passed to the most recent 2-arg {@link #normalizeStorageUri} (T09). */ Map lastVendedToken; @@ -119,6 +123,16 @@ public String normalizeStorageUri(String rawUri, Map vendedToken return rawUri == null ? null : rawUri.replaceFirst("^(oss|cos|obs|s3a)://", "s3://"); } + @Override + public UnaryOperator newStorageUriNormalizer(Map vendedToken) { + // Count the once-per-scan derivation (the perf hoist) but still record each per-URI normalize by + // delegating every apply back to the recording normalizeStorageUri — so existing recording assertions + // (normalizedUris / normalizeCount / lastVendedToken) keep firing, while newNormalizerCount proves the + // token->config derivation is entered once per scan, not once per file. + newNormalizerCount++; + return rawUri -> normalizeStorageUri(rawUri, vendedToken); + } + @Override public List getStorageProperties() { return storageProperties; 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 new file mode 100644 index 00000000000000..e6744a21e0948a --- /dev/null +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TestStatementScope.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.iceberg; + +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * A memoizing {@link ConnectorStatementScope} for iceberg unit tests. The iceberg connector module does not + * depend on fe-core, so tests cannot use the engine's {@code ConnectorStatementScopeImpl}; this is a faithful + * copy of it. Sharing one instance across a scan session and a write session mimics a single statement's scope, + * so a test can prove the read/scan/write resolvers collapse onto one load and that the rewritable-delete supply + * bridges scan→write. + */ +final class TestStatementScope implements ConnectorStatementScope { + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Supplier loader) { + return (T) cache.computeIfAbsent(key, k -> loader.get()); + } +} diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/ActionTestTables.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/ActionTestTables.java index 10edf7bc70e5ac..fd30e387646f23 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/ActionTestTables.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/ActionTestTables.java @@ -22,6 +22,7 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DataFiles; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileMetadata; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; @@ -84,6 +85,34 @@ static DataFile dataFile(String fileName, long records) { .build(); } + /** Commits a position-delete file as a new snapshot (creates a delete manifest carried forward by later ones). */ + static void addPositionDeleteSnapshot(InMemoryCatalog catalog, String table, String fileName, + String referencedDataFile) { + catalog.loadTable(id(table)).newRowDelta() + .addDeletes(FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofPositionDeletes() + .withPath("s3://b/db1/" + fileName) + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(64L) + .withRecordCount(1L) + .withReferencedDataFile("s3://b/db1/" + referencedDataFile) + .build()) + .commit(); + } + + /** Commits an equality-delete file as a new snapshot (a distinct delete manifest). */ + static void addEqualityDeleteSnapshot(InMemoryCatalog catalog, String table, String fileName) { + catalog.loadTable(id(table)).newRowDelta() + .addDeletes(FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofEqualityDeletes(1) + .withPath("s3://b/db1/" + fileName) + .withFormat(FileFormat.PARQUET) + .withFileSizeInBytes(64L) + .withRecordCount(1L) + .build()) + .commit(); + } + static ConnectorSession session(String timeZone) { return new FakeSession(timeZone); } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsActionTest.java index 29604f0f13a756..ef81cf3fed474e 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsActionTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergExpireSnapshotsActionTest.java @@ -27,14 +27,20 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.inmemory.InMemoryCatalog; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * Pins {@code expire_snapshots}: the validation pass and the six-counter result. @@ -117,6 +123,52 @@ public void resultSchemaIsSixBigintCounters() { } } + @Test + public void buildDeleteFileContentMapDedupsManifestsSharedAcrossSnapshots() { + // WHY: expire_snapshots builds the delete-file -> content classification map by scanning every snapshot's + // delete manifests. Iceberg carries immutable delete manifests forward across snapshots, so the naive + // per-snapshot scan re-reads the same manifest once per referencing snapshot. Deduping by manifest path + // must (a) read each DISTINCT delete manifest exactly once — strictly fewer than the per-snapshot total — + // and (b) leave the resulting map byte-identical (every delete path still classified correctly). A + // regression that reset the visited set per snapshot (no dedup) or skipped a distinct manifest (dropping + // its delete files) is killed here. + InMemoryCatalog catalog = ActionTestTables.freshCatalog(); + TableIdentifier id = ActionTestTables.id("t"); + ActionTestTables.createTable(catalog, "t"); + ActionTestTables.appendSnapshot(catalog, "t", "f1.parquet", 1L); + // A position-delete snapshot creates delete manifest DM1... + ActionTestTables.addPositionDeleteSnapshot(catalog, "t", "pos-del.parquet", "f1.parquet"); + // ...an equality-delete snapshot creates DM2 and carries DM1 forward... + ActionTestTables.addEqualityDeleteSnapshot(catalog, "t", "eq-del.parquet"); + // ...and two more appends carry both delete manifests forward unchanged (the redundant re-read source). + ActionTestTables.appendSnapshot(catalog, "t", "f2.parquet", 1L); + ActionTestTables.appendSnapshot(catalog, "t", "f3.parquet", 1L); + + Table table = catalog.loadTable(id); + Set distinctDeleteManifests = new HashSet<>(); + int perSnapshotReads = 0; + for (Snapshot snapshot : table.snapshots()) { + for (ManifestFile manifest : snapshot.deleteManifests(table.io())) { + distinctDeleteManifests.add(manifest.path()); + perSnapshotReads++; + } + } + // Sanity: the fixture must actually reuse a delete manifest across snapshots, else the test proves nothing. + Assertions.assertTrue(perSnapshotReads > distinctDeleteManifests.size(), + "fixture must share a delete manifest across snapshots: perSnapshot=" + perSnapshotReads + + " distinct=" + distinctDeleteManifests.size()); + + IcebergExpireSnapshotsAction action = action(ImmutableMap.of("retain_last", "1")); + Map contentByPath = action.buildDeleteFileContentMap(table); + + Assertions.assertEquals(distinctDeleteManifests.size(), action.lastDeleteManifestReadCount, + "each distinct delete manifest must be read exactly once (dedup), not once per referencing snapshot"); + Assertions.assertEquals(FileContent.POSITION_DELETES, contentByPath.get("s3://b/db1/pos-del.parquet"), + "the position-delete file must stay classified as POSITION_DELETES"); + Assertions.assertEquals(FileContent.EQUALITY_DELETES, contentByPath.get("s3://b/db1/eq-del.parquet"), + "the equality-delete file must stay classified as EQUALITY_DELETES"); + } + @Test public void rejectsNegativeOlderThanTimestamp() { DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java index 5545697e12708a..c38c8a9c91044e 100644 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.function.UnaryOperator; /** * Runtime context provided by fe-core to connector implementations. @@ -208,6 +209,28 @@ default String normalizeStorageUri(String rawUri, Map rawVendedC return normalizeStorageUri(rawUri); } + /** + * Scan-scoped batch form of {@link #normalizeStorageUri(String, Map)}: derives the vended storage + * configuration from the (scan-invariant) per-table token ONCE and returns a normalizer that applies + * it to many raw URIs cheaply. A vended-credentials scan normalizes O(N_files + N_deletes) paths but + * the token→storage-config derivation ({@code StorageProperties.createAll} + a hadoop config build) is + * a pure function of the token, so hoisting it out of the per-file loop turns O(N) heavy derivations + * into one. The connector builds the normalizer once (where it extracts the token) and reuses it for + * every data/delete/position-delete path in the scan. + * + *

    The default returns a normalizer that delegates per call to {@link #normalizeStorageUri(String, + * Map)} — behavior-identical, no hoist — so a connector with no engine context (offline unit tests) + * and any connector that does not override the engine side are unaffected. The engine + * ({@code DefaultConnectorContext}) overrides this to perform the actual once-per-scan derivation. + * + * @param rawVendedCredentials the raw per-table vended token map (may be null/empty → static path) + * @return a URI normalizer for this scan; each application is byte-identical to + * {@link #normalizeStorageUri(String, Map)} with the same token + */ + default UnaryOperator newStorageUriNormalizer(Map rawVendedCredentials) { + return rawUri -> normalizeStorageUri(rawUri, rawVendedCredentials); + } + /** * Resolves the BE-facing file type (a {@code TFileType} enum name, e.g. {@code "FILE_S3"}) for a raw * storage URI a connector emits (e.g. an iceberg write output path). A write-side analogue of diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml index 254712404947ce..065e21387f2d66 100644 --- a/fe/fe-connector/pom.xml +++ b/fe/fe-connector/pom.xml @@ -100,6 +100,25 @@ under the License. + + + check-authz-cache-sharding + validate + + exec + + + ${project.basedir}/../../tools/check-authz-cache-sharding.sh + + ${project.basedir} + + + diff --git a/fe/fe-core/pom.xml b/fe/fe-core/pom.xml index 6ef409718e1e7d..3d542c1acbaafe 100644 --- a/fe/fe-core/pom.xml +++ b/fe/fe-core/pom.xml @@ -978,6 +978,35 @@ under the License. + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + check-fecore-metadata-funnel + validate + + exec + + + ${project.basedir}/../../tools/check-fecore-metadata-funnel.sh + + ${project.basedir} + + + + + diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java index 717c505d10b52e..f26cbfe22dbf82 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java @@ -21,8 +21,10 @@ import org.apache.doris.common.util.DebugUtil; import org.apache.doris.connector.api.ConnectorDelegatedCredential; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.datasource.DelegatedCredential; import org.apache.doris.datasource.SessionContext; +import org.apache.doris.nereids.StatementContext; import org.apache.doris.qe.ConnectContext; import org.apache.doris.qe.GlobalVariable; import org.apache.doris.qe.VariableMgr; @@ -56,6 +58,9 @@ public final class ConnectorSessionBuilder { // over the ConnectContext extraction. private String sessionId; private ConnectorDelegatedCredential delegatedCredential; + // Explicit per-statement scope override for tests without a live ConnectContext; when set it wins over + // the ConnectContext capture in build(). + private ConnectorStatementScope statementScope; private ConnectorSessionBuilder() {} @@ -139,6 +144,16 @@ public ConnectorSessionBuilder withDelegatedCredential(ConnectorDelegatedCredent return this; } + /** + * Sets the per-statement scope explicitly (for callers without a live {@link ConnectContext}, e.g. tests + * that want to inject a memoizing scope). When unset, {@link #build()} captures it from the originating + * {@link ConnectContext}'s statement context, or falls back to {@link ConnectorStatementScope#NONE}. + */ + public ConnectorSessionBuilder withStatementScope(ConnectorStatementScope statementScope) { + this.statementScope = statementScope; + return this; + } + /** Builds an immutable {@link ConnectorSession} instance. */ public ConnectorSession build() { String sid = null; @@ -159,7 +174,32 @@ public ConnectorSession build() { } } return new ConnectorSessionImpl(queryId, user, timeZone, locale, - catalogId, catalogName, catalogProperties, sessionProperties, sid, cred); + catalogId, catalogName, catalogProperties, sessionProperties, sid, cred, + captureStatementScope()); + } + + /** + * Captures the per-statement scope at build time (the request thread). An explicit test override wins; + * otherwise read it off the originating {@link ConnectContext}'s statement context (preferring the + * retained context from {@link #from} over the thread-local, so a session built off the request thread + * still captures correctly). Two-level null (no context / no statement context) yields + * {@link ConnectorStatementScope#NONE} -- mirrors {@code MvccUtil.getSnapshotFromContext}. Capturing the + * reference here (not reading it live) lets off-thread scan pumps that reuse this one session reach the + * same scope even though they have no ConnectContext thread-local. + */ + private ConnectorStatementScope captureStatementScope() { + if (statementScope != null) { + return statementScope; + } + ConnectContext ctx = connectContext != null ? connectContext : ConnectContext.get(); + if (ctx == null) { + return ConnectorStatementScope.NONE; + } + StatementContext sc = ctx.getStatementContext(); + if (sc == null) { + return ConnectorStatementScope.NONE; + } + return sc.getOrCreateConnectorStatementScope(); } /** diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java index 722831d3fcff87..a273b9a9d94b2a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java @@ -20,6 +20,7 @@ import org.apache.doris.catalog.Env; import org.apache.doris.connector.api.ConnectorDelegatedCredential; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTransaction; import java.util.Collections; @@ -48,6 +49,10 @@ public class ConnectorSessionImpl implements ConnectorSession { // ConnectorDelegatedCredential); getSessionId() falls back to the queryId when no session id was captured. private final String sessionId; private final ConnectorDelegatedCredential delegatedCredential; + // The per-statement scope, captured at construction (the request thread, where the statement context is + // reachable) so off-thread scan pumps that reuse this one session still reach it. NONE when there is no + // live statement context (offline planning, tests) -- then getStatementScope() memoizes nothing. + private final ConnectorStatementScope statementScope; // Otherwise-immutable session; this is bound once by the insert executor at write time // for connectors using the SPI transaction model (e.g. maxcompute), and read back by the // connector's planWrite via getCurrentTransaction(). volatile for cross-thread visibility. @@ -57,13 +62,13 @@ public class ConnectorSessionImpl implements ConnectorSession { long catalogId, String catalogName, Map catalogProperties, Map sessionProperties) { this(queryId, user, timeZone, locale, catalogId, catalogName, catalogProperties, sessionProperties, - null, null); + null, null, ConnectorStatementScope.NONE); } ConnectorSessionImpl(String queryId, String user, String timeZone, String locale, long catalogId, String catalogName, Map catalogProperties, Map sessionProperties, String sessionId, - ConnectorDelegatedCredential delegatedCredential) { + ConnectorDelegatedCredential delegatedCredential, ConnectorStatementScope statementScope) { this.queryId = queryId != null ? queryId : ""; this.user = user != null ? user : ""; this.timeZone = timeZone != null ? timeZone : "UTC"; @@ -76,6 +81,7 @@ public class ConnectorSessionImpl implements ConnectorSession { ? Collections.unmodifiableMap(sessionProperties) : Collections.emptyMap(); this.sessionId = sessionId; this.delegatedCredential = delegatedCredential; + this.statementScope = statementScope != null ? statementScope : ConnectorStatementScope.NONE; } @Override @@ -174,6 +180,11 @@ public Optional getCurrentTransaction() { return Optional.ofNullable(currentTransaction); } + @Override + public ConnectorStatementScope getStatementScope() { + return statementScope; + } + @Override public String toString() { return "ConnectorSession{queryId='" + queryId 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 new file mode 100644 index 00000000000000..56720ef42f1bb6 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorStatementScopeImpl.java @@ -0,0 +1,99 @@ +// 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; + +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.datasource.plugin.CatalogStatementTransaction; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Statement-scoped memoization arena backing {@link ConnectorStatementScope}, hung on the per-statement + * {@link org.apache.doris.nereids.StatementContext}. + * + *

    Thread-safe by a backing {@link ConcurrentHashMap}: a scan's off-thread pumps (streaming / + * partition-batch) reuse the single {@link org.apache.doris.connector.api.ConnectorSession} built on the + * request thread and so reach this same scope concurrently. {@code computeIfAbsent} gives every caller of + * a key the same instance — required for the shared table object and for the delete supply map that scan + * and write both mutate. The loaders used by connectors do not re-enter this scope, so the map's + * single-key atomicity is safe here.

    + */ +public class ConnectorStatementScopeImpl implements ConnectorStatementScope { + + private static final Logger LOG = LogManager.getLogger(ConnectorStatementScopeImpl.class); + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private boolean closed; + + @Override + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Supplier loader) { + return (T) cache.computeIfAbsent(key, k -> loader.get()); + } + + /** + * Tears the statement's scoped values down at statement end, in two ordered passes. Pass 1 finalizes any + * {@link CatalogStatementTransaction} (rolling back a transaction the executor never committed — only a + * mid-flight abort leaves one active); pass 2 closes every remaining {@link AutoCloseable} value (the + * memoized metadata, etc.). Transactions are finalized BEFORE the metadata they were minted from is closed. + * Idempotent: guarded by {@code closed} so a second trigger (the query-finish callback vs. a reused prepared + * statement's per-execution reset) is a harmless no-op and no value is double-closed. Best-effort per value — + * a failure on one does not abort the rest — mirroring the isolation of the engine's query-finish callback + * registry. Runs after the scan off-thread pumps have quiesced, so it does not race a concurrent + * {@code computeIfAbsent}. + */ + @Override + public synchronized void closeAll() { + if (closed) { + return; + } + closed = true; + // Pass 1: finalize the statement's write transaction(s) first, so a transaction aborted mid-flight is + // rolled back / released before pass 2 closes the shared metadata instance it was minted from. On every + // normal path the executor already finished the transaction, so this is a no-op. + for (Object value : cache.values()) { + if (value instanceof CatalogStatementTransaction) { + try { + ((CatalogStatementTransaction) value).finalizeAtStatementEnd(); + } catch (Exception e) { + LOG.warn("failed to finalize per-statement transaction; continuing", e); + } + } + } + // Pass 2: close every remaining AutoCloseable value once (the transaction holders handled above are + // skipped here). + for (Object value : cache.values()) { + if (value instanceof CatalogStatementTransaction) { + continue; + } + if (value instanceof AutoCloseable) { + try { + ((AutoCloseable) value).close(); + } catch (Exception e) { + LOG.warn("failed to close per-statement scope value of type {}; continuing", + value.getClass().getName(), e); + } + } + } + cache.clear(); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java b/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java index 8aa89490f55321..522e3782517787 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java @@ -66,6 +66,7 @@ import java.util.concurrent.Callable; import java.util.function.Function; import java.util.function.Supplier; +import java.util.function.UnaryOperator; import java.util.stream.Collectors; /** @@ -408,6 +409,37 @@ public String normalizeStorageUri(String rawUri, Map rawVendedCr return LocationPath.of(rawUri, effective).toStorageLocation().toString(); } + @Override + public UnaryOperator newStorageUriNormalizer(Map rawVendedCredentials) { + // PERF: the vended token is scan-invariant, so derive the effective storage map (the expensive + // buildVendedStorageMap = StorageProperties.createAll + hadoop config build) ONCE per scan and reuse + // it for every per-file normalize, instead of rebuilding it per data/delete file. Each application is + // byte-identical to normalizeStorageUri(rawUri, token): the SAME empty-uri short-circuit, the SAME + // vended-replaces-static precedence, the SAME fail-loud LocationPath. The derivation is done LAZILY on + // the first non-empty URI (not eagerly at construction) so a scan that normalizes zero non-empty URIs + // triggers no derivation — preserving the exact exception timing of the per-call method. The returned + // normalizer is single-threaded per scan (the streaming pump drives one thread; the synchronous and + // position-delete loops are single-threaded), so the memo needs no lock. + return new UnaryOperator() { + private Map effective; + private boolean built; + + @Override + public String apply(String rawUri) { + if (Strings.isNullOrEmpty(rawUri)) { + return rawUri; + } + if (!built) { + Map vended = + buildVendedStorageMap(rawVendedCredentials); + effective = vended != null ? vended : storagePropertiesSupplier.get(); + built = true; + } + return LocationPath.of(rawUri, effective).toStorageLocation().toString(); + } + }; + } + @Override public String getBackendFileType(String rawUri, Map rawVendedCredentials) { // Same LocationPath build as normalizeStorageUri (vended-aware), then read the BE file type from diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java index af0ce3bd0f9704..e461473eecf553 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java @@ -349,6 +349,25 @@ protected boolean shouldBypassDbNameCache(SessionContext ctx) { return false; } + /** + * Returns whether the shared table-schema cache should be skipped for the current session (the schema-level + * analog of {@link #shouldBypassTableNameCache}). + * + *

    The generic schema cache is keyed by table NAME only ({@link SchemaCacheKey} wraps a + * {@link org.apache.doris.datasource.NameMapping} with no user/identity dimension), so under a connector + * whose remote {@code loadTable} enforces PER-USER authorization (Iceberg REST {@code session=user}) a shared + * hit would serve one user's schema to another who could list the table but is NOT authorized to load it — + * the "list != load" metadata disclosure the db/table-name-cache bypasses already close. Such catalogs + * bypass the shared cache and read schema live under the current session's credential. Default {@code false} + * — every other catalog keeps the cache.

    + * + * @param ctx session context for the current request + * @return true if the schema must be read live from the remote source for this session + */ + protected boolean shouldBypassSchemaCache(SessionContext ctx) { + return false; + } + /** * check if the specified table exist. * 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 7b0d6f94daa7f0..ade57ee4262db6 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 @@ -433,8 +433,16 @@ public List getChunkSizes() { } public Optional getSchemaCacheValue() { - return Env.getCurrentEnv().getExtMetaCacheMgr() - .getSchemaCacheValue(this, new SchemaCacheKey(getOrBuildNameMapping())); + SchemaCacheKey key = new SchemaCacheKey(getOrBuildNameMapping()); + if (catalog.shouldBypassSchemaCache(SessionContext.current())) { + // session=user + delegated credential: the remote loadTable returns PER-USER schema and authorizes + // per user, so the shared name-keyed schema cache must be bypassed (mirror the db/table-name-cache + // bypass) — read schema live under the current session's credential. This is exactly what the cache + // miss-loader would do (initSchemaAndUpdateTime); calling it directly just skips the shared cache so + // one user's schema is never served to another who could list but not load the table. + return initSchemaAndUpdateTime(key); + } + return Env.getCurrentEnv().getExtMetaCacheMgr().getSchemaCacheValue(this, key); } /** 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 2cdd3ecc04ea77..bb3edc19f44fad 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 @@ -45,6 +45,7 @@ import org.apache.doris.datasource.SchemaCacheValue; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.datasource.plugin.PluginDrivenSchemaCacheValue; import org.apache.doris.mtmv.MTMVBaseTableIf; import org.apache.doris.mtmv.MTMVMaxTimestampSnapshot; @@ -140,7 +141,7 @@ private PluginDrivenMvccSnapshot materializeLatest() { Collections.emptyMap(), Collections.emptyMap()); } ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { @@ -355,7 +356,7 @@ public MvccSnapshot loadSnapshot(Optional tableSnapshot, Optional PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); String dbName = db != null ? db.getRemoteName() : ""; String tableName = getRemoteName(); Optional handleOpt = resolveConnectorTableHandle(session, metadata); @@ -722,7 +723,7 @@ private Optional resolveFreshnessProbe() { return Optional.empty(); } ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); return handleOpt.map(handle -> new FreshnessProbe(session, metadata, handle)); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/CatalogStatementTransaction.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/CatalogStatementTransaction.java new file mode 100644 index 00000000000000..9e15d57fcce1c1 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/CatalogStatementTransaction.java @@ -0,0 +1,108 @@ +// 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.plugin; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorWriteOps; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.transaction.PluginDrivenTransactionManager; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * The per-statement owner of a plugin-driven write transaction, co-held on the statement scope next to the one + * memoized {@link org.apache.doris.connector.api.ConnectorMetadata} the read and write arms share — mirroring + * Trino's {@code CatalogTransaction}: one metadata instance and one transaction per (statement, catalog). The + * insert executor opens the transaction through {@link #begin}, minting it from that shared write-ops facet so + * the write inherits exactly the read arm's client/ops, and commits / rolls back through the + * {@link PluginDrivenTransactionManager} as before. + * + *

    This object's own job is the deterministic statement-end backstop. {@link #finalizeAtStatementEnd()} rolls + * back a transaction the executor never committed or rolled back — only a mid-flight abort leaves one active — + * and the scope runs it BEFORE closing the shared metadata, so a transaction is always finished before the + * instance it was minted from is closed. It is idempotent and can never undo a committed write: on every normal + * path the executor has already finished the transaction, which removes it from the manager, so the backstop + * finds nothing active and does nothing.

    + * + *

    fe-core-internal and connector-agnostic: it traffics only in the neutral {@link ConnectorWriteOps} / + * {@link ConnectorSession} / {@link ConnectorTransaction} SPI types (never a concrete connector type) and is + * stored on the connector-agnostic {@link org.apache.doris.connector.api.ConnectorStatementScope} as an opaque + * value, recognized by {@link org.apache.doris.connector.ConnectorStatementScopeImpl} only to order its + * teardown before metadata close.

    + */ +public final class CatalogStatementTransaction { + + private static final Logger LOG = LogManager.getLogger(CatalogStatementTransaction.class); + + /** Sentinel for "no transaction opened yet" (never a real connector txn id). */ + public static final long INVALID_TXN_ID = -1L; + + // The write facet of the statement's one shared metadata instance; the transaction is minted from it. Held + // for co-hold coherence — the metadata itself is closed by the scope's generic pass, this class only + // finalizes the transaction. + private final ConnectorWriteOps writeOps; + private final ConnectorSession session; + private final PluginDrivenTransactionManager transactionManager; + + private ConnectorTransaction connectorTx; + private long txnId = INVALID_TXN_ID; + + public CatalogStatementTransaction(ConnectorWriteOps writeOps, ConnectorSession session, + PluginDrivenTransactionManager transactionManager) { + this.writeOps = writeOps; + this.session = session; + this.transactionManager = transactionManager; + } + + /** + * Opens the write transaction from the shared metadata and registers it with the manager (which also + * publishes it in the global registry the BE block-allocation RPC / commit-data feedback look up by id), + * returning it so the executor can bind it onto the sink session. Called once, from the insert executor's + * {@code beginTransaction}. + */ + public ConnectorTransaction begin(ConnectorTableHandle writeHandle) { + connectorTx = writeOps.beginTransaction(session, writeHandle); + txnId = transactionManager.begin(connectorTx); + return connectorTx; + } + + public long getTransactionId() { + return txnId; + } + + public ConnectorTransaction getConnectorTransaction() { + return connectorTx; + } + + /** + * Statement-end backstop: if the transaction is still active (the executor never reached commit / rollback, + * i.e. the statement was aborted mid-flight), roll it back. On every normal path the executor already + * finished it — the manager no longer holds it — so this is a no-op and can never undo a committed write. + * The scope runs this before it closes the shared metadata instance. + */ + public void finalizeAtStatementEnd() { + if (txnId == INVALID_TXN_ID || !transactionManager.isActive(txnId)) { + return; + } + LOG.warn("Statement ended with an uncommitted plugin-driven transaction {}; rolling it back as a backstop", + txnId); + transactionManager.rollback(txnId); + } +} 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 0985a6e795632c..d1b87d5d9f0d67 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 @@ -40,6 +40,7 @@ import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorTestResult; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; @@ -291,8 +292,8 @@ public void onRefreshCache(boolean invalidCache) { @Override protected List listDatabaseNames() { try { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session).listDatabaseNames(session); + ConnectorSession session = buildCrossStatementSession(); + return PluginDrivenMetadata.get(session, connector).listDatabaseNames(session); } catch (RuntimeException e) { // The connector connects lazily: initLocalObjectsImpl() only constructs it, so the // first metastore round-trip happens here — inside the meta-cache loader, which runs @@ -307,8 +308,8 @@ protected List listDatabaseNames() { @Override protected List listTableNamesFromRemote(SessionContext ctx, String dbName) { - ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorSession session = buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); List tableNames = metadata.listTableNames(session, dbName); if (!connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VIEW)) { return tableNames; @@ -329,7 +330,7 @@ protected List listTableNamesFromRemote(SessionContext ctx, String dbNam @Override public boolean tableExist(SessionContext ctx, String dbName, String tblName) { ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session) + return PluginDrivenMetadata.get(session, connector) .getTableHandle(session, dbName, tblName).isPresent(); } @@ -419,7 +420,7 @@ public boolean createTable(CreateTableInfo createTableInfo) throws UserException + "' in catalog: " + getName()); } ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); // Mirror legacy MaxComputeMetadataOps.createTableImpl:178-197 -- probe BOTH the remote // (connector) and the local FE cache for an existing table. On IF NOT EXISTS this lets CTAS // short-circuit (Env.createTable contract: return true when the table already exists), so a @@ -498,7 +499,7 @@ public void createDb(String dbName, boolean ifNotExists, Map pro return; } ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); // FE-cache miss but the db may already exist REMOTELY (created on another FE / before this // FE's db-name cache was populated). Legacy MaxComputeMetadataOps.createDbImpl consulted // BOTH getDbNullable AND the remote databaseExist, and IF NOT EXISTS then no-oped. Mirror @@ -547,7 +548,7 @@ public void dropDb(String dbName, boolean ifExists, boolean force) throws DdlExc } ConnectorSession session = buildConnectorSession(); try { - connector.getMetadata(session).dropDatabase(session, db.getRemoteName(), ifExists, force); + PluginDrivenMetadata.get(session, connector).dropDatabase(session, db.getRemoteName(), ifExists, force); } catch (DorisConnectorException e) { throw new DdlException(e.getMessage(), e); } @@ -595,7 +596,7 @@ public void dropTable(String dbName, String tableName, boolean isView, boolean i throw new DdlException("Failed to get table: '" + tableName + "' in database: " + dbName); } ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); // Route a DROP on a VIEW to dropView, mirroring legacy IcebergMetadataOps.dropTableImpl's // viewExists -> performDropView dispatch: a connector that exposes views keeps them in a separate // namespace, so getTableHandle/tableExists below is false for a view and the table-handle path @@ -667,7 +668,7 @@ public void renameTable(String dbName, String oldTableName, String newTableName) throw new DdlException("Failed to get table: '" + oldTableName + "' in database: " + dbName); } ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(dorisTable, session, metadata); try { metadata.renameTable(session, handle, newTableName); @@ -711,7 +712,7 @@ public void truncateTable(String dbName, String tableName, PartitionNamesInfo pa } List partitions = partitionNamesInfo == null ? null : partitionNamesInfo.getPartitionNames(); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(dorisTable, session, metadata); try { metadata.truncateTable(session, handle, partitions); @@ -804,7 +805,7 @@ public void replayCreateTable(String dbName, String tblName) { public void addColumn(TableIf dorisTable, Column column, ColumnPosition position) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -820,7 +821,7 @@ public void addColumn(TableIf dorisTable, Column column, ColumnPosition position public void addColumns(TableIf dorisTable, List columns) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -835,7 +836,7 @@ public void addColumns(TableIf dorisTable, List columns) throws UserExce public void dropColumn(TableIf dorisTable, String columnName) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -850,7 +851,7 @@ public void dropColumn(TableIf dorisTable, String columnName) throws UserExcepti public void renameColumn(TableIf dorisTable, String oldName, String newName) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -865,7 +866,7 @@ public void renameColumn(TableIf dorisTable, String oldName, String newName) thr public void modifyColumn(TableIf dorisTable, Column column, ColumnPosition position) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -881,7 +882,7 @@ public void modifyColumn(TableIf dorisTable, Column column, ColumnPosition posit public void reorderColumns(TableIf dorisTable, List newOrder) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -912,7 +913,7 @@ public void createOrReplaceBranch(TableIf dorisTable, CreateOrReplaceBranchInfo throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -928,7 +929,7 @@ public void createOrReplaceBranch(TableIf dorisTable, CreateOrReplaceBranchInfo public void createOrReplaceTag(TableIf dorisTable, CreateOrReplaceTagInfo tagInfo) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -944,7 +945,7 @@ public void createOrReplaceTag(TableIf dorisTable, CreateOrReplaceTagInfo tagInf public void dropBranch(TableIf dorisTable, DropBranchInfo branchInfo) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -960,7 +961,7 @@ public void dropBranch(TableIf dorisTable, DropBranchInfo branchInfo) throws Use public void dropTag(TableIf dorisTable, DropTagInfo tagInfo) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -985,7 +986,7 @@ public void dropTag(TableIf dorisTable, DropTagInfo tagInfo) throws UserExceptio public void addPartitionField(TableIf dorisTable, AddPartitionFieldOp op) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -1000,7 +1001,7 @@ public void addPartitionField(TableIf dorisTable, AddPartitionFieldOp op) throws public void dropPartitionField(TableIf dorisTable, DropPartitionFieldOp op) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -1015,7 +1016,7 @@ public void dropPartitionField(TableIf dorisTable, DropPartitionFieldOp op) thro public void replacePartitionField(TableIf dorisTable, ReplacePartitionFieldOp op) throws UserException { ExternalTable externalTable = checkExternalTable(dorisTable); ConnectorSession session = buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle handle = resolveAlterHandle(externalTable, session, metadata); long updateTime = System.currentTimeMillis(); try { @@ -1102,14 +1103,15 @@ protected void afterExternalRename(String dbName, String oldTableName, String ne @Override public String fromRemoteDatabaseName(String remoteDatabaseName) { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session).fromRemoteDatabaseName(session, remoteDatabaseName); + ConnectorSession session = buildCrossStatementSession(); + return PluginDrivenMetadata.get(session, connector).fromRemoteDatabaseName(session, remoteDatabaseName); } @Override public String fromRemoteTableName(String remoteDatabaseName, String remoteTableName) { - ConnectorSession session = buildConnectorSession(); - return connector.getMetadata(session).fromRemoteTableName(session, remoteDatabaseName, remoteTableName); + ConnectorSession session = buildCrossStatementSession(); + return PluginDrivenMetadata.get(session, connector) + .fromRemoteTableName(session, remoteDatabaseName, remoteTableName); } /** @@ -1136,6 +1138,37 @@ public ConnectorSession buildConnectorSession() { .build(); } + /** + * Builds a {@link ConnectorSession} for a CROSS-STATEMENT background loader — one that fills a cache + * living longer than any single statement (database/table name caches, schema cache, column-statistic + * cache, row-count cache, the BE-driven metadata TVF). Identical to {@link #buildConnectorSession()} + * (same credential handling) except the per-statement scope is forced to + * {@link ConnectorStatementScope#NONE}. That makes the read-through a contract rather than an accident: + * a metadata resolved through {@link PluginDrivenMetadata#get} with this session is built fresh and never + * memoized into — nor closed with — some live statement's scope, even when the loader happens to run on a + * request/ANALYZE thread that has one (e.g. {@code fetchRowCount} reached synchronously from + * {@code AnalysisManager.buildAnalysisJobInfo}). Under NONE the funnel memoizes nothing, so this is + * byte-identical to a bare {@code getMetadata} call. + */ + public ConnectorSession buildCrossStatementSession() { + ConnectContext ctx = ConnectContext.get(); + if (ctx != null) { + return ConnectorSessionBuilder.from(ctx) + .withCatalogId(getId()) + .withCatalogName(getName()) + .withCatalogProperties(catalogProperty.getProperties()) + .withUserSessionCapability(supportsUserSession()) + .withStatementScope(ConnectorStatementScope.NONE) + .build(); + } + return ConnectorSessionBuilder.create() + .withCatalogId(getId()) + .withCatalogName(getName()) + .withCatalogProperties(catalogProperty.getProperties()) + .withStatementScope(ConnectorStatementScope.NONE) + .build(); + } + /** * Whether the backing connector projects the querying user's delegated credential onto the remote * metadata source ({@link ConnectorCapability#SUPPORTS_USER_SESSION}), gating both the FE credential @@ -1168,6 +1201,18 @@ protected boolean shouldBypassDbNameCache(SessionContext ctx) { return supportsUserSession() && ctx != null && ctx.hasDelegatedCredential(); } + /** + * Schema-level analog of {@link #shouldBypassTableNameCache}: under a session=user connector with a per-request + * credential the remote {@code loadTable} returns PER-USER schema (and authorizes per user), so the shared + * name-keyed schema cache is bypassed to avoid serving one user's schema to another who could list but not + * load the table (the "list != load" disclosure). Same capability + credential gate; a session with no + * credential keeps the shared cache and the fail-closed rejection happens connector-side. + */ + @Override + protected boolean shouldBypassSchemaCache(SessionContext ctx) { + return supportsUserSession() && ctx != null && ctx.hasDelegatedCredential(); + } + @Override protected ExternalDatabase buildDbForInit(String remoteDbName, String localDbName, long dbId, InitCatalogLog.Type logType, boolean checkExists) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabase.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabase.java index a8ccb7515049e7..324d72ab886d95 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabase.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabase.java @@ -81,7 +81,7 @@ public String getLocation() { return ""; } ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorDatabaseMetadata dbMetadata = metadata.getDatabase(session, getRemoteName()); return dbMetadata.getProperties().getOrDefault(ConnectorDatabaseMetadata.LOCATION_PROPERTY, ""); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java index 56d056ce7142f0..8df8e69eb10009 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java @@ -130,7 +130,7 @@ public Optional resolveConnectorTableHandle( public ConnectorTableHandle resolveWriteTargetHandle() { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; ConnectorSession session = pluginCatalog.buildConnectorSession(); - return resolveConnectorTableHandle(session, pluginCatalog.getConnector().getMetadata(session)) + return resolveConnectorTableHandle(session, PluginDrivenMetadata.get(session, pluginCatalog.getConnector())) .orElseThrow(() -> new DorisConnectorException( "Cannot resolve the connector table handle for write target " + getName())); } @@ -156,7 +156,7 @@ public List getSyntheticScanPredicates(MvccSnapshot snapsho ConnectorMvccSnapshot connectorSnapshot = ((PluginDrivenMvccSnapshot) snapshot).getConnectorSnapshot(); PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = pluginCatalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Collections.emptyList(); @@ -186,7 +186,7 @@ public boolean supportsParallelWrite() { */ private Optional resolveWriteCapabilityHandle(Connector connector) { ConnectorSession session = ((PluginDrivenExternalCatalog) catalog).buildConnectorSession(); - return resolveConnectorTableHandle(session, connector.getMetadata(session)); + return resolveConnectorTableHandle(session, PluginDrivenMetadata.get(session, connector)); } /** @@ -456,8 +456,8 @@ public Optional initSchema() { } } Connector connector = pluginCatalog.getConnector(); - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); String dbName = db != null ? db.getRemoteName() : ""; String tableName = getRemoteName(); @@ -573,7 +573,7 @@ protected boolean resolveIsView() { return false; } ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); String dbName = db != null ? db.getRemoteName() : ""; return metadata.viewExists(session, dbName, getRemoteName()); } @@ -591,7 +591,7 @@ public String getViewText() { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); String dbName = db != null ? db.getRemoteName() : ""; ConnectorViewDefinition definition = metadata.getViewDefinition(session, dbName, getRemoteName()); return definition.getSql(); @@ -614,7 +614,7 @@ public Optional getShowCreateTableDdl() { return Optional.empty(); } ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Optional.empty(); @@ -724,7 +724,7 @@ private List fetchSyntheticWriteColumns() { // so a read-only connector now resolves the handle here — a no-op for a write-capable connector, which // resolved it regardless. ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Collections.emptyList(); @@ -817,7 +817,7 @@ public Map getNameToPartitionItems(Optional PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Collections.emptyMap(); @@ -878,7 +878,7 @@ public Map> getNameToPartitionValues(Optional PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Collections.emptyMap(); @@ -915,7 +915,7 @@ public List> getMetadataTableRows(String kind) { return Collections.emptyList(); } ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Collections.emptyList(); @@ -947,7 +947,7 @@ public String getComment(boolean escapeQuota) { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); String tableName = getRemoteName(); String comment = metadata.getTableComment(session, remoteDbName, tableName); if (escapeQuota && comment != null) { @@ -978,7 +978,7 @@ public Map getSupportedSysTables() { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Collections.emptyMap(); @@ -1048,8 +1048,8 @@ public Optional getColumnStatistic(String colName) { if (connector == null) { return Optional.empty(); } - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Optional.empty(); @@ -1083,8 +1083,8 @@ public List getChunkSizes() { if (connector == null) { return Collections.emptyList(); } - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { return Collections.emptyList(); @@ -1149,8 +1149,8 @@ public long fetchRowCount() { makeSureInitialized(); PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); - ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorSession session = pluginCatalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); Optional handleOpt = resolveConnectorTableHandle(session, metadata); if (!handleOpt.isPresent()) { @@ -1301,7 +1301,7 @@ public TTableDescriptor toThrift() { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; Connector connector = pluginCatalog.getConnector(); ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); String dbName = db != null ? db.getRemoteName() : ""; List schema = getFullSchema(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenMetadata.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenMetadata.java new file mode 100644 index 00000000000000..cd12453144836c --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenMetadata.java @@ -0,0 +1,72 @@ +// 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.plugin; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; + +import java.util.Objects; + +/** + * The single engine-side funnel through which every metadata acquisition in a statement flows, so that a + * statement uses exactly ONE {@link ConnectorMetadata} instance per catalog: it memoizes the result of + * {@code connector.getMetadata(session)} on the session's per-statement {@link ConnectorStatementScope} and hands + * every read / scan / DDL / MVCC resolver the same instance, closed deterministically at statement end. + * + *

    Under {@link ConnectorStatementScope#NONE} (offline / no live statement) the scope memoizes nothing, so the + * factory runs on every call — {@code connector.getMetadata(session)} per call, byte-identical to today's behavior. + * The {@link ConnectorSession} is still built and passed to metadata operations as before; only the + * {@code getMetadata} result is memoized here, never the session.

    + * + *

    This class is the ONLY place in fe-core allowed to call {@code Connector#getMetadata} directly (an arch gate + * enforces that every other seam routes through here). A heterogeneous gateway connector that fans a foreign + * handle out to a sibling connector keys the memo by the owning connector as well as the catalog id; that + * sibling-aware entry is added when the gateway is wired.

    + */ +public final class PluginDrivenMetadata { + + private PluginDrivenMetadata() { + } + + /** + * Returns the statement's single memoized {@link ConnectorMetadata} for {@code connector}, building it via + * {@code connector.getMetadata(session)} on first use and reusing it for the rest of the statement. Keyed by + * the session's catalog id, so a cross-catalog statement resolves each catalog independently. + */ + public static ConnectorMetadata get(ConnectorSession session, Connector connector) { + ConnectorStatementScope scope = session.getStatementScope(); + long catalogId = session.getCatalogId(); + // A statement resolves a catalog under exactly one identity (one statement = one user = one credential). + // Now that the write arm reuses the read arm's memoized instance, and a session=user connector bakes the + // querying user's delegated credential into that instance at getMetadata time, reusing it under a second + // identity would execute one user's operation with another's credentials. Pin the building identity and + // fail loud if a different one ever reuses the instance, turning a silent cross-user leak into a hard + // error. Uses the Doris principal (getUser), never a credential token, so fe-core parses no credentials. + // Under NONE this stores nothing, so the check is vacuously true and the factory runs on every call. + String user = session.getUser(); + String builderUser = scope.computeIfAbsent("metadata-identity:" + catalogId, () -> user); + if (!Objects.equals(builderUser, user)) { + throw new IllegalStateException("Per-statement metadata identity mismatch for catalog " + catalogId + + ": the instance was built for user '" + builderUser + "' but is being reused for user '" + + user + "'. A statement must resolve a catalog under a single identity."); + } + return scope.getOrCreateMetadata("metadata:" + catalogId, () -> connector.getMetadata(session)); + } +} 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 050c75c796872f..d70715a0342163 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 @@ -33,6 +33,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.PassthroughQueryTableHandle; @@ -56,6 +57,7 @@ import org.apache.doris.datasource.mvcc.PluginDrivenMvccSnapshot; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.datasource.plugin.PluginDrivenSysExternalTable; import org.apache.doris.datasource.split.FileSplit; import org.apache.doris.datasource.split.PluginDrivenSplit; @@ -177,6 +179,15 @@ public class PluginDrivenScanNode extends FileQueryScanNode { // Maps filtered conjunct indices (after CAST removal) back to original conjunct indices private List filteredToOriginalIndex; + // Memoized resolveScanProvider() result, keyed on currentHandle identity. See resolveScanProvider(). + // Volatile so the concurrent partition-batch appendBatch threads read a self-consistent (handle, provider). + private volatile ResolvedScanProvider resolvedScanProvider; + + // This node's per-statement ConnectorMetadata. The funnel already memoizes it in the statement scope + // (keyed by catalogId); cached here as well so the per-method resolvers don't re-hit the scope map. + // Volatile mirrors resolvedScanProvider — keeps an off-path metadata() read race-free. + private volatile ConnectorMetadata cachedMetadata; + public PluginDrivenScanNode(PlanNodeId id, TupleDescriptor desc, boolean needCheckColumnPriv, SessionVariable sv, ScanContext scanContext, Connector connector, @@ -187,6 +198,17 @@ public PluginDrivenScanNode(PlanNodeId id, TupleDescriptor desc, this.currentHandle = tableHandle; } + // Lazily resolves this node's ConnectorMetadata through the per-statement funnel and caches it, so the + // per-method resolvers below share one instance for the statement instead of rebuilding it each time. + private ConnectorMetadata metadata() { + ConnectorMetadata m = cachedMetadata; + if (m == null) { + m = PluginDrivenMetadata.get(connectorSession, connector); + cachedMetadata = m; + } + return m; + } + /** * Creates a PluginDrivenScanNode by resolving the connector, session, and table handle * from the plugin-driven catalog and table. @@ -197,7 +219,7 @@ public static PluginDrivenScanNode create(PlanNodeId id, TupleDescriptor desc, PluginDrivenExternalTable table) { Connector connector = catalog.getConnector(); ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); String dbName = table.getDb() != null ? table.getDb().getRemoteName() : ""; // Resolve through the table's sys-aware seam (NOT raw metadata.getTableHandle): for a normal // table this is identical to getTableHandle(session, dbName, remoteName), but for a @@ -221,7 +243,35 @@ public static PluginDrivenScanNode create(PlanNodeId id, TupleDescriptor desc, * {@code null} for connectors without scan capability. */ private ConnectorScanPlanProvider resolveScanProvider() { - return connector.getScanPlanProvider(currentHandle); + // Memoized per currentHandle IDENTITY. The provider is a pure function of currentHandle (SPI contract: + // providers are built fresh/stateless per call and the selected provider does not change across a scan), + // so the per-split getFileCompressType / getDeleteFiles hot path reuses one instance instead of + // re-allocating + TCCL-swapping a provider per split. currentHandle is only refined (a new object) during + // early pushdown/pin, before split enumeration; an identity miss re-resolves — making this byte-identical + // to calling connector.getScanPlanProvider(currentHandle) every time. The immutable holder is published via + // one volatile write so the concurrent partition-batch appendBatch threads always read a self-consistent + // (handle, provider) pair; a null provider (no scan capability) is cached and returned correctly. + ResolvedScanProvider cached = resolvedScanProvider; + if (cached == null || cached.handle != currentHandle) { + cached = new ResolvedScanProvider(currentHandle, connector.getScanPlanProvider(currentHandle)); + resolvedScanProvider = cached; + } + return cached.provider; + } + + /** + * Immutable (handle, provider) pair for {@link #resolveScanProvider()}'s memo. Both fields final so a single + * volatile write of the holder safely publishes the pair to concurrent readers (no torn new-key/old-provider + * view). {@code provider} may be {@code null} for a connector without scan capability. + */ + private static final class ResolvedScanProvider { + private final ConnectorTableHandle handle; + private final ConnectorScanPlanProvider provider; + + ResolvedScanProvider(ConnectorTableHandle handle, ConnectorScanPlanProvider provider) { + this.handle = handle; + this.provider = provider; + } } /** @@ -769,7 +819,7 @@ protected void convertPredicate() { if (conjuncts == null || conjuncts.isEmpty()) { return; } - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); ConnectorFilterConstraint constraint = buildFilterConstraint(conjuncts); Optional> result = metadata.applyFilter(connectorSession, currentHandle, constraint); @@ -809,7 +859,7 @@ private void tryPushDownLimit() { if (limit <= 0) { return; } - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); Optional> result = metadata.applyLimit(connectorSession, currentHandle, limit); if (result.isPresent()) { @@ -828,7 +878,7 @@ private void tryPushDownProjection(List columns) { if (columns.isEmpty()) { return; } - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); Optional> result = metadata.applyProjection(connectorSession, currentHandle, columns); if (result.isPresent()) { @@ -873,7 +923,7 @@ public static ConnectorTableHandle applyMvccSnapshotPin(ConnectorMetadata metada * split path and the serialized-table path read at the pinned snapshot. */ private void pinMvccSnapshot() throws UserException { - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); // Version-aware lookup: a statement mixing main and @branch/@tag (or FOR-TIME) of the SAME table // pins one snapshot per reference; resolve THIS scan's reference by its own selector so it reads // its own snapshot (not whichever reference loaded first). getQueryTableSnapshot()/getScanParams() @@ -1018,7 +1068,7 @@ private void pinRewriteFileScope() { if (scope == null || scope.isEmpty()) { return; } - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); currentHandle = applyRewriteFileScopePin(metadata, connectorSession, currentHandle, scope); } @@ -1037,7 +1087,7 @@ private void pinTopnLazyMaterialize() { if (!hasTopnLazyMaterializeSlot(desc.getSlots())) { return; } - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); currentHandle = metadata.applyTopnLazyMaterialization(connectorSession, currentHandle); } @@ -1175,6 +1225,18 @@ public List getSplits(int numBackends) throws UserException { QeProcessorImpl.INSTANCE.registerQueryFinishCallback(readTxnQueryId, buildReadTransactionReleaseCallback(scanProvider, readTxnQueryId)); + // Deterministic close of the per-statement metadata scope, on the SAME query-finish hook and the SAME + // query-id key as the read-transaction release above. This is the PRIMARY close: getSplits runs only for + // coordinated scans, all of which reach unregisterQuery, so it fires after off-thread pump quiescence and + // leaves no dangling registry entry. Object-capture (scope::closeAll binds THIS scope instance) so a retry + // / prepared-EXECUTE that swaps the StatementContext field can never let this callback touch a successor + // scope. Skip NONE (off-thread / no-ConnectContext builds carry NONE and hold nothing to close). Non-scan + // statements (DDL / SHOW / EXPLAIN via Command.run) never reach here and are closed by StatementContext. + ConnectorStatementScope statementScope = connectorSession.getStatementScope(); + if (statementScope != ConnectorStatementScope.NONE) { + QeProcessorImpl.INSTANCE.registerQueryFinishCallback(readTxnQueryId, statementScope::closeAll); + } + // Push the Nereids partition-pruning result down to the connector so the read session // covers only the surviving partitions. A pruned-to-zero set means no data to read, // mirroring legacy MaxComputeScanNode.getSplits()'s empty-selection short-circuit — UNLESS the @@ -1853,7 +1915,7 @@ private static TFileFormatType mapFileFormatType(String format) { * enabling optimized column selection (e.g., SELECT col1, col2 instead of SELECT *). */ private List buildColumnHandles() { - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); Map allHandles = metadata.getColumnHandles(connectorSession, currentHandle); if (allHandles.isEmpty()) { @@ -1890,7 +1952,7 @@ private Optional buildRemainingFilter() { return Optional.empty(); } List pushableConjuncts = conjuncts; - ConnectorMetadata metadata = connector.getMetadata(connectorSession); + ConnectorMetadata metadata = metadata(); if (!metadata.supportsCastPredicatePushdown(connectorSession)) { filteredToOriginalIndex = new ArrayList<>(); pushableConjuncts = new ArrayList<>(); 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 d3027ed8da3494..5541d8ae57dee4 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 @@ -30,6 +30,8 @@ import org.apache.doris.common.Id; import org.apache.doris.common.IdGenerator; import org.apache.doris.common.Pair; +import org.apache.doris.connector.ConnectorStatementScopeImpl; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTransaction; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.mvcc.MvccSnapshot; @@ -198,6 +200,14 @@ public enum TableFrom { private final Set keySlots = Sets.newHashSet(); private BitSet disableRules; + // A per-statement memoization arena for connectors: e.g. Iceberg loads a table once and shares that + // single object across read + write resolvers within the statement. Lazily built (see + // getOrCreateConnectorStatementScope), values stored opaquely by the connector. Like snapshots, it is + // NOT cleared on close()/releasePlannerResources -- it survives to statement GC. A reused + // prepared-statement context drops it per execution via resetConnectorStatementScope() (see + // ExecuteCommand) so one execution's cached tables never leak into the next. + private ConnectorStatementScope connectorStatementScope; + // table locks private final Stack plannerResources = new Stack<>(); @@ -637,6 +647,35 @@ public synchronized BitSet getOrCacheDisableRules(SessionVariable sessionVariabl return this.disableRules; } + /** + * Returns this statement's connector scope, lazily creating it on first use (mirrors + * {@link #getOrCacheDisableRules}). A connector reaches it through + * {@link org.apache.doris.connector.api.ConnectorSession#getStatementScope()} to load a table once and + * share it across the statement's read + write resolvers. + */ + public synchronized ConnectorStatementScope getOrCreateConnectorStatementScope() { + if (this.connectorStatementScope == null) { + this.connectorStatementScope = new ConnectorStatementScopeImpl(); + } + return this.connectorStatementScope; + } + + /** + * Closes (deterministically) then drops the connector scope so the next statement execution starts fresh. + * Prepared-statement EXECUTE reuses one StatementContext across executions (see + * {@link org.apache.doris.nereids.trees.plans.commands.ExecuteCommand}) and a retry reuses it across attempts; + * this is called at the start of each, so a prior execution's / attempt's cached tables and closeable state + * are released (closeAll is idempotent — a no-op if the query-finish callback already closed it) and never + * leak into the next. Callers invoke this only after the prior execution/attempt has finished (no off-thread + * pump still running), so close-before-drop is safe. + */ + public synchronized void resetConnectorStatementScope() { + if (this.connectorStatementScope != null) { + this.connectorStatementScope.closeAll(); + } + this.connectorStatementScope = null; + } + /** * Some value of the cacheKey may change, invalid cache when value change */ @@ -945,6 +984,17 @@ protected void finalize() throws Throwable { @Override public void close() { releasePlannerResources(); + // Fallback deterministic close of the per-statement connector scope, for statements that never reach the + // query-finish callback: external DDL / SHOW / DESCRIBE / EXPLAIN / foreground ANALYZE run via Command.run + // with no coordinator, so PluginDrivenScanNode.getSplits never registers a primary close for them. close() + // runs in ConnectProcessor's per-statement finally (direct connection) and the forwarded-request finally + // (master side), so it covers them. Guarded by isReturnResultFromLocal so an arrow-flight statement (which + // returns results asynchronously and defers cleanup to its own query-finish close) is not closed early + // here. Idempotent (closeAll is close-once), so a coordinated statement whose scope the primary already + // closed is a no-op. Command statements have no off-thread scan pump, so close-after-use holds. + if (connectorStatementScope != null && connectContext != null && connectContext.isReturnResultFromLocal()) { + connectorStatementScope.closeAll(); + } } public List getPlaceholders() { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java index f548564a50aca8..246d9271b1bff1 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java @@ -57,6 +57,7 @@ import org.apache.doris.datasource.mvcc.MvccUtil; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.datasource.scan.FileQueryScanNode; import org.apache.doris.datasource.scan.PluginDrivenScanNode; import org.apache.doris.nereids.exceptions.AnalysisException; @@ -609,7 +610,7 @@ private PluginDrivenTableSink buildPluginRowLevelDmlSink( Connector connector = catalog.getConnector(); ConnectorSession connSession = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(connSession); + ConnectorMetadata metadata = PluginDrivenMetadata.get(connSession, connector); List connectorColumns = sink.getCols().stream() .map(col -> new ConnectorColumn(col.getName(), @@ -657,7 +658,7 @@ public PlanFragment visitPhysicalConnectorTableSink( // Get write config from the connector Connector connector = catalog.getConnector(); ConnectorSession connSession = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(connSession); + ConnectorMetadata metadata = PluginDrivenMetadata.get(connSession, connector); // Convert sink columns to connector columns for INSERT SQL generation List connectorColumns = connectorTableSink.getCols().stream() diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java index 1a7d41cd811f85..e474fd30cbdea6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java @@ -41,6 +41,7 @@ import org.apache.doris.datasource.doris.RemoteDorisExternalTable; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.dictionary.Dictionary; import org.apache.doris.nereids.CascadesContext; import org.apache.doris.nereids.StatementContext; @@ -670,7 +671,7 @@ private void checkConnectorStaticPartitions(PluginDrivenExternalTable table, } PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = catalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); ConnectorTableHandle handle = metadata.getTableHandle( session, table.getRemoteDbName(), table.getRemoteName()) .orElseThrow(() -> new AnalysisException("Table not found: " @@ -709,7 +710,7 @@ private void checkConnectorWritePartitionNames(PluginDrivenExternalTable table, } PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = catalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); ConnectorTableHandle handle = metadata.getTableHandle( session, table.getRemoteDbName(), table.getRemoteName()) .orElseThrow(() -> new AnalysisException("Table not found: " diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java index df50bbf6266ecd..45b5c7d70410ee 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java @@ -89,6 +89,10 @@ public void run(ConnectContext ctx, StmtExecutor executor) throws Exception { StatementContext statementContext = preparedStmtCtx.getStatementContext(); statementContext.setPrepareStage(false); statementContext.setIsInsert(false); + // A prepared EXECUTE reuses this one StatementContext across executions; drop the connector + // per-statement scope so a prior execution's cached tables/state never leak into this one (the + // scope key's queryId is a second line of defense). See StatementContext#resetConnectorStatementScope. + statementContext.resetConnectorStatementScope(); LogicalPlan logicalPlan = prepareCommand.getLogicalPlan(); if (logicalPlan instanceof LogicalSqlCache) { throw new AnalysisException("Unsupported sql cache for server prepared statement"); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransform.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransform.java index 6d846ae7408458..fb2c5d679f2ea7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransform.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransform.java @@ -29,6 +29,7 @@ import org.apache.doris.datasource.connector.converter.WriteConstraintExtractor; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.trees.expressions.SlotReference; @@ -109,7 +110,7 @@ public void checkMode(TableIf table, RowLevelDmlOp op) { private static void checkPluginMode(PluginDrivenExternalTable table, RowLevelDmlOp op) { PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = catalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); ConnectorTableHandle handle = metadata.getTableHandle( session, table.getRemoteDbName(), table.getRemoteName()) .orElseThrow(() -> new AnalysisException("Table not found: " diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommand.java index 4e6643ba7b0c6b..28d6f0cf525739 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommand.java @@ -46,6 +46,7 @@ import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.analyzer.UnboundSlot; import org.apache.doris.nereids.properties.OrderKey; @@ -282,7 +283,7 @@ private ShowResultSet handleShowPluginDrivenTablePartitions() throws AnalysisExc // Route partition listing through the connector SPI. ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = pluginCatalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); ConnectorTableHandle handle = metadata .getTableHandle(session, dorisTable.getRemoteDbName(), dorisTable.getRemoteName()) .orElseThrow(() -> new AnalysisException( diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/call/CallExecuteStmtFunc.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/call/CallExecuteStmtFunc.java index 6cda00f7425947..6f7624795635bc 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/call/CallExecuteStmtFunc.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/call/CallExecuteStmtFunc.java @@ -23,6 +23,7 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.nereids.trees.expressions.Expression; @@ -98,7 +99,7 @@ public void run() { if (catalogIf instanceof PluginDrivenExternalCatalog) { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalogIf; ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = pluginCatalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); metadata.executeStmt(session, stmt); } else { throw new AnalysisException("executeStmt not supported for catalog type: " 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 6ceb8acabd70d9..2db11166bbc635 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 @@ -43,6 +43,7 @@ import org.apache.doris.datasource.connector.converter.UnboundExpressionToConnectorPredicateConverter; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.mysql.privilege.PrivPredicate; import org.apache.doris.nereids.trees.expressions.Expression; import org.apache.doris.qe.CommonResultSet; @@ -125,7 +126,7 @@ public ResultSet execute(TableIf ignored) throws UserException { // (single-call and distributed) also need the session/handle. Resolving the handle first means a bad // table name now surfaces "Table not found" before "does not support EXECUTE". ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); ConnectorTableHandle tableHandle = metadata .getTableHandle(session, table.getRemoteDbName(), table.getRemoteName()) .orElseThrow(() -> new AnalysisException("Table not found: " + table.getRemoteDbName() diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriver.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriver.java index b85c672fb50b7b..87a982a073f2fe 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriver.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriver.java @@ -42,8 +42,10 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -137,12 +139,16 @@ public ConnectorProcedureResult run() throws UserException { // STEP 2: run one INSERT-SELECT per group concurrently, all sharing the transaction. runGroups(groups, txnId, connectorTx); - // STEP 3: register the union of source data files to remove. AFTER the groups ran, so the first - // group's write loaded the table + pinned the OCC snapshot that the connector re-derives against; - // BEFORE commit, which consumes the registered files in the RewriteFiles op. - for (ConnectorRewriteGroup group : groups) { - connectorTx.registerRewriteSourceFiles(group.getDataFilePaths()); - } + // STEP 3: register the UNION of every group's source data files in a SINGLE call. The connector + // re-derives them from the table at the pinned OCC snapshot with ONE planFiles() scan; the former + // per-group loop repeated that full-table scan once per group (G groups = G+1 scans). Ordering is + // unchanged — still AFTER the groups ran (so the first group's write loaded the table + pinned the + // OCC snapshot that the connector re-derives against) and BEFORE commit (which consumes the + // registered files in the RewriteFiles op). Every per-group call scanned the SAME pinned snapshot, so + // one union scan is equivalent; the connector's registration accumulates and dedups by path, and the + // planner emits path-DISJOINT groups (iceberg: planFiles() yields one task per data file, bin-packed + // into disjoint groups), so the union reconstructs exactly the per-group calls' accumulated file set. + connectorTx.registerRewriteSourceFiles(unionSourceFilePaths(groups)); } catch (Exception e) { txnManager.rollback(txnId); if (e instanceof UserException) { @@ -167,6 +173,20 @@ public ConnectorProcedureResult run() throws UserException { removedDeleteFilesCount); } + /** + * Unions every group's source data-file paths into one dedup'd set, so the connector re-derives them all in + * a single {@code planFiles()} scan (STEP 3) instead of one scan per group. Bin-packed groups are + * path-disjoint so this is a straight union; the connector's own per-path dedup keeps the registered file set + * exact regardless. Package-visible for unit testing (the full distributed STEP 3 needs a live cluster). + */ + static Set unionSourceFilePaths(List groups) { + Set sourceFilePaths = new HashSet<>(); + for (ConnectorRewriteGroup group : groups) { + sourceFilePaths.addAll(group.getDataFilePaths()); + } + return sourceFilePaths; + } + private void runGroups(List groups, long txnId, ConnectorTransaction connectorTx) throws UserException { List tasks = Lists.newArrayList(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java index 4bd1ebd8f265e6..ab3265c18927f7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/PluginDrivenInsertExecutor.java @@ -25,8 +25,10 @@ import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.ConnectorTransaction; import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.plugin.CatalogStatementTransaction; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.nereids.trees.plans.physical.PhysicalSink; import org.apache.doris.planner.DataSink; @@ -86,8 +88,19 @@ public void beginTransaction() { // downcasts; a single-format connector ignores the handle (the SPI default delegates to the no-arg // beginTransaction). resolveWriteTargetHandle fails loud rather than handing the gateway a null handle. ConnectorTableHandle writeHandle = ((PluginDrivenExternalTable) table).resolveWriteTargetHandle(); - connectorTx = writeOps.beginTransaction(connectorSession, writeHandle); - txnId = ((PluginDrivenTransactionManager) transactionManager).begin(connectorTx); + // Co-hold the transaction with the statement's one shared metadata (writeOps) + session on the statement + // scope, so read and write share one instance and the scope deterministically rolls back a transaction + // aborted mid-flight at statement end -- before it closes that shared metadata. begin() still mints from + // writeOps and registers with the manager (global lookup for the BE block-allocation RPC / commit-data + // feedback), returning the transaction for the sink-session binding in finalizeSink. Under NONE the scope + // stores nothing, so the holder is transient and the executor's own commit/rollback remains the only + // lifecycle (byte-identical to the pre-co-holder path). + CatalogStatementTransaction stmtTxn = (CatalogStatementTransaction) connectorSession.getStatementScope() + .computeIfAbsent("txn:" + connectorSession.getCatalogId(), + () -> new CatalogStatementTransaction(writeOps, connectorSession, + (PluginDrivenTransactionManager) transactionManager)); + connectorTx = stmtTxn.begin(writeHandle); + txnId = connectorTx.getTransactionId(); } @Override @@ -203,6 +216,6 @@ private void ensureConnectorSetup() { (PluginDrivenExternalCatalog) ((ExternalTable) table).getCatalog(); Connector connector = catalog.getConnector(); connectorSession = catalog.buildConnectorSession(); - writeOps = connector.getMetadata(connectorSession); + writeOps = PluginDrivenMetadata.get(connectorSession, connector); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java index e6d4cfddda55fc..3f390ec10ae0af 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalIcebergMergeSink.java @@ -29,6 +29,7 @@ import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.nereids.memo.GroupExpression; import org.apache.doris.nereids.properties.DistributionSpecHash.ShuffleType; import org.apache.doris.nereids.properties.DistributionSpecMerge; @@ -300,7 +301,7 @@ private InsertPartitionFieldResult buildInsertPartitionFieldsFromConnector( PluginDrivenExternalCatalog catalog = (PluginDrivenExternalCatalog) table.getCatalog(); Connector connector = catalog.getConnector(); ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = connector.getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); // Resolve the handle first so the write provider is selected per-table (a heterogeneous gateway routes // iceberg-on-HMS to its sibling by the handle type); both null-degrade checks keep the non-partitioned // fallback. Byte-identical for single-format connectors (getWritePlanProvider(handle) defaults through). diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java index 72bbf95a372ce9..c83397f58dc0e7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java @@ -784,6 +784,16 @@ public TMasterOpResult proxyExecute(TMasterOpRequest request) throws TException // If reach here, maybe Doris bug. LOG.warn("Process one query failed because unknown reason: ", e); ctx.getState().setError(ErrorCode.ERR_UNKNOWN_ERROR, "Unexpected exception: " + e.getMessage()); + } finally { + // Master-side forwarded statements (redirect DDL / SHOW) execute via Command.run and never reach + // unregisterQuery, so their per-statement connector scope has no query-finish trigger. Close it here + // via StatementContext.close() (the same per-statement close the direct-connection path runs in its + // finally). Idempotent: a coordinated forwarded query's scope is already closed by its query-finish + // callback. These statements run synchronously with no off-thread scan pump, so close-after-use holds. + StatementContext forwardedStatementContext = ctx.getStatementContext(); + if (forwardedStatementContext != null) { + forwardedStatementContext.close(); + } } // no matter the master execute success or fail, the master must transfer the result to follower // and tell the follower the current journalID. diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java index 92f698b1950b36..7bad37645b7cae 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java @@ -1069,6 +1069,13 @@ private void handleQueryWithRetry(TUniqueId queryId) throws Exception { DebugUtil.printId(queryId), i, DebugUtil.printId(newQueryId)); context.setQueryId(newQueryId); context.setNeedRegenerateInstanceId(newQueryId); + // Each retry attempt gets a fresh per-statement connector scope. The previous attempt's scope + // was closed by its own query-finish callback (registered under the previous query id), so + // reusing it would memoize into an already-closed scope whose values then never close. Reset + // closes (idempotent) and drops it; this attempt builds a fresh one under the new query id. + if (context.getStatementContext() != null) { + context.getStatementContext().resetConnectorStatementScope(); + } if (Config.isCloudMode()) { // sleep random millis [1000, 1500] ms // in the begining of retryTime/2 diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java index aa0c4293678b68..f8250bee8a84b5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/JdbcQueryTableValueFunction.java @@ -26,6 +26,7 @@ import org.apache.doris.connector.api.handle.PassthroughQueryTableHandle; import org.apache.doris.datasource.connector.converter.ConnectorColumnConverter; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.datasource.scan.PluginDrivenScanNode; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.ScanContext; @@ -49,7 +50,7 @@ public JdbcQueryTableValueFunction(Map params) throws AnalysisEx public List getTableColumns() throws AnalysisException { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalogIf; ConnectorSession session = pluginCatalog.buildConnectorSession(); - ConnectorMetadata metadata = pluginCatalog.getConnector().getMetadata(session); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); ConnectorTableSchema schema = metadata.getColumnsFromQuery(session, query); return ConnectorColumnConverter.convertColumns(schema.getColumns()); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java index 277b1095a8d1da..be08a36b69f4cb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/MetadataGenerator.java @@ -72,6 +72,7 @@ import org.apache.doris.datasource.mvcc.MvccUtil; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; +import org.apache.doris.datasource.plugin.PluginDrivenMetadata; import org.apache.doris.job.common.JobType; import org.apache.doris.job.extensions.insert.streaming.AbstractStreamingTask; import org.apache.doris.job.extensions.insert.streaming.StreamingInsertJob; @@ -1325,8 +1326,8 @@ private static TFetchSchemaTableDataResult partitionMetadataResult(TMetadataTabl private static TFetchSchemaTableDataResult dealPluginDrivenCatalog(PluginDrivenExternalCatalog catalog, ExternalTable table) { List dataBatch = Lists.newArrayList(); - ConnectorSession session = catalog.buildConnectorSession(); - ConnectorMetadata metadata = catalog.getConnector().getMetadata(session); + ConnectorSession session = catalog.buildCrossStatementSession(); + ConnectorMetadata metadata = PluginDrivenMetadata.get(session, catalog.getConnector()); Optional handle = metadata.getTableHandle( session, table.getRemoteDbName(), table.getRemoteName()); if (handle.isPresent()) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java b/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java index bb65871c88b16a..16c230f8d7de40 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/transaction/PluginDrivenTransactionManager.java @@ -114,6 +114,16 @@ public Transaction getTransaction(long id) throws UserException { return txn; } + /** + * Whether transaction {@code id} is still open here, i.e. registered by {@link #begin(ConnectorTransaction)} + * and not yet committed or rolled back (both {@link #commit(long)} and {@link #rollback(long)} remove it). + * The statement scope's end-of-statement backstop uses this to tell a mid-flight-aborted transaction from + * one the executor already finished, so it rolls back only genuine orphans and never a committed write. + */ + public boolean isActive(long id) { + return transactions.containsKey(id); + } + /** * Internal transaction record. When {@code connectorTx} is non-null (every plugin-driven * write) the SPI is the source of truth and commit/rollback delegate to it; close() always 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 8e61d5f8e1b707..e745aa6af8d289 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 @@ -19,7 +19,10 @@ import org.apache.doris.connector.api.ConnectorDelegatedCredential; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.qe.ConnectContext; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -273,6 +276,37 @@ public void capableConnectorWithNoOfferedCredentialCarriesNone() { Assertions.assertFalse(session.getDelegatedCredential().isPresent()); } + @Test + public void explicitNoneStatementScopeWinsOverLiveContext() { + // A cross-statement background loader (PluginDrivenExternalCatalog#buildCrossStatementSession) forces the + // per-statement scope to NONE so a metadata it resolves is never memoized into — nor closed with — the + // live statement's scope, even when the loader runs on a thread that has one (e.g. fetchRowCount reached + // synchronously from AnalysisManager.buildAnalysisJobInfo on the ANALYZE execution thread). This pins the + // builder guarantee that helper relies on: an explicit withStatementScope(NONE) wins over the scope + // captured from the live ConnectContext. MUTATION: capture ignoring the explicit override -> the loader + // binds to the live statement scope (the leak) -> red. + ConnectContext ctx = new ConnectContext(); + ctx.setThreadLocalInfo(); + try { + StatementContext stmtCtx = new StatementContext(); + ctx.setStatementContext(stmtCtx); + ConnectorStatementScope live = stmtCtx.getOrCreateConnectorStatementScope(); + + // A default session capture binds to the live statement scope (the path a loader must avoid). + ConnectorSession bound = ConnectorSessionBuilder.from(ctx).withCatalogId(1L).build(); + Assertions.assertSame(live, bound.getStatementScope(), + "a default session capture binds to the live statement scope"); + + // The forced-NONE session (what buildCrossStatementSession builds) wins over the live scope. + ConnectorSession crossStatement = ConnectorSessionBuilder.from(ctx).withCatalogId(1L) + .withStatementScope(ConnectorStatementScope.NONE).build(); + Assertions.assertSame(ConnectorStatementScope.NONE, crossStatement.getStatementScope(), + "an explicit NONE scope wins over the live statement context (forced read-through)"); + } finally { + ConnectContext.remove(); + } + } + /** Minimal hand-written {@link ConnectorTransaction}; only identity matters for this test. */ private static final class StubConnectorTransaction implements ConnectorTransaction { private final long txnId; 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 new file mode 100644 index 00000000000000..b7d868e4a3f43e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorStatementScopeTest.java @@ -0,0 +1,214 @@ +// 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; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.api.ConnectorWriteOps; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.datasource.plugin.CatalogStatementTransaction; +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.transaction.PluginDrivenTransactionManager; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tests for the per-statement {@link ConnectorStatementScope}: the {@link ConnectorStatementScope#NONE} no-op, + * the memoizing {@link ConnectorStatementScopeImpl}, and the {@link StatementContext} hosting + per-execution + * reset a reused prepared statement relies on. + */ +public class ConnectorStatementScopeTest { + + @Test + public void noneNeverMemoizes() { + // NONE = the off-context default (offline / no live statement): the loader runs every call, so a + // connector under NONE behaves byte-identically to loading every time. MUTATION: NONE memoizing -> the + // second call reuses the first value -> red. + AtomicInteger loads = new AtomicInteger(); + ConnectorStatementScope none = ConnectorStatementScope.NONE; + Object first = none.computeIfAbsent("k", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Object second = none.computeIfAbsent("k", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Assertions.assertEquals(2, loads.get(), "NONE must run the loader every time (no memo)"); + Assertions.assertNotSame(first, second, "NONE returns a fresh value each call"); + } + + @Test + public void implMemoizesPerKeyAndIsolatesKeys() { + // The real scope memoizes per key: the same key returns the same instance to every caller (this is what + // makes a statement's read/scan/write resolvers share ONE loaded table), and the loader runs once. + // MUTATION: not memoizing -> two instances / two loads -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger loads = new AtomicInteger(); + Object firstA = scope.computeIfAbsent("A", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Object secondA = scope.computeIfAbsent("A", () -> { + loads.incrementAndGet(); + return new Object(); + }); + Assertions.assertSame(firstA, secondA, "the same key returns the same instance (read/write share one load)"); + Assertions.assertEquals(1, loads.get(), "the loader runs once per key"); + + Object b = scope.computeIfAbsent("B", Object::new); + Assertions.assertNotSame(firstA, b, "different keys are isolated (cross-catalog / cross-table)"); + } + + @Test + public void statementContextScopeIsStableThenResetsForReusedPreparedContext() { + // StatementContext lazily builds one scope and reuses it across the statement (getOrCreate...). A prepared + // EXECUTE reuses one StatementContext across executions; resetConnectorStatementScope() (called by + // ExecuteCommand each execution) drops it so a prior execution's memoized state never leaks into the next. + // MUTATION: reset not clearing the field -> s2 == s1 and the stale value leaks -> red. + StatementContext ctx = new StatementContext(); + ConnectorStatementScope s1 = ctx.getOrCreateConnectorStatementScope(); + Assertions.assertSame(s1, ctx.getOrCreateConnectorStatementScope(), + "the scope is lazily created once and reused across a statement"); + + Object memoized = s1.computeIfAbsent("k", Object::new); + ctx.resetConnectorStatementScope(); + + ConnectorStatementScope s2 = ctx.getOrCreateConnectorStatementScope(); + Assertions.assertNotSame(s1, s2, "reset drops the scope so a reused prepared context starts fresh"); + Assertions.assertNotSame(memoized, s2.computeIfAbsent("k", Object::new), + "a prior execution's memoized value must not leak into the next execution"); + } + + @Test + public void closeAllClosesCloseableValuesOnceAndIgnoresPlainValues() throws Exception { + // closeAll() closes every AutoCloseable value the statement memoized (a ConnectorMetadata is Closeable) + // and leaves non-closeable values (the shared table object, the scan->write delete-supply map) untouched. + // It must be idempotent: the engine fires it from more than one locus (the query-finish callback + a reused + // prepared statement's per-execution reset), so a second call must not double-close. + // MUTATION: dropping the close-once guard -> closes==2 -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + scope.computeIfAbsent("closeable", () -> closeable); + scope.computeIfAbsent("plain", Object::new); // a non-closeable value must be ignored, not crash + + scope.closeAll(); + scope.closeAll(); + + Assertions.assertEquals(1, closes.get(), + "each closeable value is closed exactly once across repeated closeAll (idempotent)"); + } + + @Test + public void closeAllFinalizesTransactionsBeforeClosingMetadata() { + // Two-pass teardown: the scope must finalize (roll back an orphaned) write transaction BEFORE it closes + // the shared metadata instance the transaction was minted from, so the transaction is never left holding a + // closed instance. MUTATION: single-pass close (or closing metadata first) -> the recorded order flips + // -> red. + List order = new ArrayList<>(); + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = Mockito.mock(ConnectorTransaction.class); + Mockito.when(tx.getTransactionId()).thenReturn(90001L); + Mockito.doAnswer(inv -> { + order.add("txn-rollback"); + return null; + }).when(tx).rollback(); + ConnectorWriteOps ops = Mockito.mock(ConnectorWriteOps.class); + Mockito.when(ops.beginTransaction(Mockito.any(), Mockito.any())).thenReturn(tx); + + ConnectorStatementScopeImpl scope = new ConnectorStatementScopeImpl(); + // The statement's shared metadata: a closeable that records the moment it is closed. + AutoCloseable metadata = () -> order.add("metadata-close"); + scope.computeIfAbsent("metadata:1", () -> metadata); + CatalogStatementTransaction holder = + new CatalogStatementTransaction(ops, Mockito.mock(ConnectorSession.class), mgr); + scope.computeIfAbsent("txn:1", () -> holder); + holder.begin(new ConnectorTableHandle() { }); // active orphan: the executor never committed it + + scope.closeAll(); + + Assertions.assertEquals(Arrays.asList("txn-rollback", "metadata-close"), order, + "the transaction is finalized before the shared metadata is closed"); + } + + @Test + public void resetClosesTheDroppedScopeBeforeStartingFresh() { + // A prepared EXECUTE / retry reuses one StatementContext and calls resetConnectorStatementScope() at the + // start of each execution/attempt. Reset must CLOSE the outgoing scope's closeable values (else the prior + // execution's tables/FileIO leak once close() does real work) and then drop it so the next starts fresh. + // MUTATION: reset only nulling (not closing) -> closes==0 -> red. + StatementContext ctx = new StatementContext(); + ConnectorStatementScope s1 = ctx.getOrCreateConnectorStatementScope(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + s1.computeIfAbsent("k", () -> closeable); + + ctx.resetConnectorStatementScope(); + + Assertions.assertEquals(1, closes.get(), "reset closes the dropped scope's closeable values before dropping it"); + Assertions.assertNotSame(s1, ctx.getOrCreateConnectorStatementScope(), + "reset drops the scope so the next execution/attempt starts fresh"); + } + + @Test + public void closeClosesScopeWhenReturningResultLocally() { + // A statement that never reaches the query-finish callback (external DDL / SHOW via Command.run) is + // closed by StatementContext.close(), the fallback ConnectProcessor runs in its per-statement finally. + // It fires only when the result is produced locally. MUTATION: close() not closing the scope -> red. + ConnectContext connectContext = Mockito.mock(ConnectContext.class); + Mockito.when(connectContext.isReturnResultFromLocal()).thenReturn(true); + StatementContext ctx = new StatementContext(connectContext, null); + ConnectorStatementScope scope = ctx.getOrCreateConnectorStatementScope(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + scope.computeIfAbsent("k", () -> closeable); + + ctx.close(); + + Assertions.assertEquals(1, closes.get(), "close() closes the scope for a locally-returned statement"); + } + + @Test + public void closeDefersScopeCloseForAsyncResult() { + // An arrow-flight statement returns results asynchronously (isReturnResultFromLocal == false) and defers + // scope cleanup to its own query-finish close; StatementContext.close() must NOT close it early here, or an + // in-flight fetch would touch a closed scope. MUTATION: dropping the guard -> closes==1 -> red. + ConnectContext connectContext = Mockito.mock(ConnectContext.class); + Mockito.when(connectContext.isReturnResultFromLocal()).thenReturn(false); + StatementContext ctx = new StatementContext(connectContext, null); + ConnectorStatementScope scope = ctx.getOrCreateConnectorStatementScope(); + AtomicInteger closes = new AtomicInteger(); + AutoCloseable closeable = () -> closes.incrementAndGet(); + scope.computeIfAbsent("k", () -> closeable); + + ctx.close(); + + Assertions.assertEquals(0, closes.get(), + "close() defers to the query-finish callback for async (arrow-flight) results"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextNormalizeUriTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextNormalizeUriTest.java index ff22c40f949f92..b2f95c634b8fb8 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextNormalizeUriTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextNormalizeUriTest.java @@ -30,6 +30,7 @@ import java.util.Map; import java.util.function.Function; import java.util.function.Supplier; +import java.util.function.UnaryOperator; import java.util.stream.Collectors; /** @@ -169,4 +170,57 @@ public void backendFileTypeVendedRestResolvesUnderEmptyStaticMap() { Assertions.assertEquals(TFileType.FILE_S3.name(), restCtx.getBackendFileType("oss://bkt/warehouse/db/t/data", ossVendedToken())); } + + // ---- FIX-PERF-06: newStorageUriNormalizer hoists the (scan-invariant) token->storage-config + // derivation to ONCE per scan; every application must stay byte-identical to a per-call + // normalizeStorageUri(uri, token), across all four cases the per-call form covers. ---- + + @Test + public void newNormalizerVendedMatchesPerCallAndServesManyUris() { + // WHY: the scan-scoped normalizer bakes the vended token in once, then normalizes many paths; + // each application must equal normalizeStorageUri(uri, token) (REST empty-static -> vended + // replaces static), and ONE normalizer must serve multiple files (the whole point of the hoist). + // MUTATION: dropping the token (static-only) throws; a stale/rebuilt map yielding a different path + // -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + UnaryOperator n = restCtx.newStorageUriNormalizer(ossVendedToken()); + Assertions.assertEquals(restCtx.normalizeStorageUri("oss://bkt/a/f1.parquet", ossVendedToken()), + n.apply("oss://bkt/a/f1.parquet")); + Assertions.assertEquals("s3://bkt/a/f1.parquet", n.apply("oss://bkt/a/f1.parquet")); + // Reuse the SAME normalizer for a second, different path — one derivation, many applications. + Assertions.assertEquals("s3://bkt/b/f2.parquet", n.apply("oss://bkt/b/f2.parquet")); + } + + @Test + public void newNormalizerStaticMapMatchesPerCallUnderEmptyToken() throws Exception { + // WHY: with a static OSS map and an empty token, the normalizer folds to the static-map path, + // byte-identical to the per-call form. MUTATION: an empty token suppressing the static map -> red. + DefaultConnectorContext ctx = ossContext(); + UnaryOperator n = ctx.newStorageUriNormalizer(Collections.emptyMap()); + Assertions.assertEquals( + ctx.normalizeStorageUri("oss://bkt/warehouse/db/t/part-0.parquet", Collections.emptyMap()), + n.apply("oss://bkt/warehouse/db/t/part-0.parquet")); + } + + @Test + public void newNormalizerShortCircuitsNullAndBlankWithoutForcingDerivation() throws Exception { + // WHY: same empty-uri short-circuit as normalizeStorageUri — a null/blank path returns unchanged + // and never reaches the fail-loud LocationPath, even on an empty static map + empty token (so a + // scan that only ever sees blank uris triggers no derivation/throw). MUTATION: NPE / fabricated + // output / forcing the derivation to throw -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + UnaryOperator n = restCtx.newStorageUriNormalizer(Collections.emptyMap()); + Assertions.assertNull(n.apply(null)); + Assertions.assertEquals("", n.apply("")); + } + + @Test + public void newNormalizerFailsLoudOnBadPathLikePerCall() { + // WHY: fail-loud parity — an empty static map + empty token has no credential, so applying to a + // real oss:// path must throw (not ship the raw path to BE), exactly like normalizeStorageUri. + // MUTATION: swallowing to the raw path -> red. + DefaultConnectorContext restCtx = new DefaultConnectorContext("c", 1L); + UnaryOperator n = restCtx.newStorageUriNormalizer(Collections.emptyMap()); + Assertions.assertThrows(RuntimeException.class, () -> n.apply("oss://bkt/a/part-0.parquet")); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java index ed375c42fd3da6..c14f8f3765e0ce 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalTableSchemaCacheDelegationTest.java @@ -23,6 +23,7 @@ import com.google.common.collect.Lists; import org.junit.Assert; import org.junit.Test; +import org.mockito.Mockito; import java.util.List; import java.util.Optional; @@ -42,6 +43,46 @@ public void testGetFullSchemaReturnsNullWhenSchemaCacheMissing() { Assert.assertNull(table.getFullSchema()); } + @Test + public void getSchemaCacheValueBypassesSharedCacheUnderSessionUser() { + // Read-site routing for the "list != load" fix: when the catalog reports bypass (session=user + delegated + // credential), getSchemaCacheValue reads schema LIVE via initSchema and NEVER consults the shared + // name-keyed cache (Env.getExtMetaCacheMgr), so one user's schema is not served to another who can list + // but not load the table. MUTATION: dropping the bypass branch -> this bare unit reaches Env (null) and + // NPEs instead of returning the live sentinel. + List live = Lists.newArrayList(new Column("c1", PrimitiveType.INT)); + ExternalCatalog catalog = Mockito.mock(ExternalCatalog.class); + Mockito.when(catalog.shouldBypassSchemaCache(Mockito.any())).thenReturn(true); + BypassProbeTable table = new BypassProbeTable(catalog, live); + + Optional result = table.getSchemaCacheValue(); + Assert.assertTrue(result.isPresent()); + Assert.assertEquals(live, result.get().getSchema()); + Assert.assertEquals("schema was read live, once, through initSchema (not the shared cache)", + 1, table.initSchemaCalls); + } + + private static final class BypassProbeTable extends ExternalTable { + private final List live; + private int initSchemaCalls; + + private BypassProbeTable(ExternalCatalog catalog, List live) { + this.catalog = catalog; + this.live = live; + } + + @Override + public NameMapping getOrBuildNameMapping() { + return NameMapping.createForTest("db", "tbl"); + } + + @Override + public Optional initSchema() { + initSchemaCalls++; + return Optional.of(new SchemaCacheValue(live)); + } + } + private static final class DelegatingExternalTable extends ExternalTable { private final Optional schemaCacheValue; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenSysTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenSysTableTest.java index c396d580551aaa..94a0fe54313f69 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenSysTableTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenSysTableTest.java @@ -22,6 +22,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorTableHandle; @@ -65,6 +66,7 @@ public class PluginDrivenSysTableTest { public void testGetSupportedSysTablesDelegatesToConnector() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); ExternalDatabase db = mockDb("REMOTE_DB"); @@ -91,6 +93,7 @@ public void testGetSupportedSysTablesDelegatesToConnector() { public void testGetSupportedSysTablesMapsTvfKindToPartitionsSysTable() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("hms", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) @@ -128,6 +131,7 @@ public void testGetSupportedSysTablesMapsTvfKindToPartitionsSysTable() { public void testGetSupportedSysTablesEmptyWhenNoBaseHandle() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) .thenReturn(Optional.empty()); @@ -145,6 +149,7 @@ public void testGetSupportedSysTablesEmptyWhenNoBaseHandle() { public void testFindSysTableResolvesBySuffix() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) @@ -169,6 +174,7 @@ public void testFindSysTableResolvesBySuffix() { public void testCreateSysExternalTableReportsPluginTypeAndName() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) @@ -208,6 +214,7 @@ public void testSysTableThreadsSysHandleNotBaseHandle() { // query reads the system table, not the base. This is the whole point of T18. ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); ConnectorTableHandle sysHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); @@ -252,6 +259,7 @@ protected synchronized void makeSureInitialized() { public void testSysTableEmptyWhenBaseHandleMissing() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) .thenReturn(Optional.empty()); @@ -276,6 +284,7 @@ protected synchronized void makeSureInitialized() { public void sysExternalTableReportsBaseTableMysqlTypeMatchingLegacyIceberg() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); PluginDrivenExternalTable base = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); PluginDrivenSysExternalTable sys = new PluginDrivenSysExternalTable(base, "snapshots"); @@ -294,6 +303,7 @@ public void sysExternalTableReportsBaseTableMysqlTypeMatchingLegacyIceberg() { public void sysExternalTableReportsIcebergEngineAndEngineTableTypeName() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); PluginDrivenExternalTable base = bareTable(catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); PluginDrivenSysExternalTable sys = new PluginDrivenSysExternalTable(base, "snapshots"); @@ -316,6 +326,7 @@ public void sysExternalTableReportsIcebergEngineAndEngineTableTypeName() { public void positionDeletesAbsentWhenConnectorDoesNotListIt() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) @@ -349,6 +360,7 @@ public void positionDeletesAbsentWhenConnectorDoesNotListIt() { public void sysExternalTableIsTransientNeitherRegisteredNorSerialized() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("iceberg", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) @@ -434,6 +446,11 @@ public ConnectorSession buildConnectorSession() { return session; } + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + @Override protected List listDatabaseNames() { return Collections.emptyList(); 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 f36542c84d41ad..ffbdff5e476569 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 @@ -32,6 +32,7 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorTableHandle; @@ -394,6 +395,7 @@ public void testNoCacheReadsFreshSchemaElseCached() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); TestablePluginCatalog catalog = new TestablePluginCatalog(metadata, session); Connector connector = catalog.getConnector(); ExternalDatabase db = mockDb("REMOTE_DB"); @@ -551,6 +553,7 @@ public void testMaterializeLatestNullConnectorDegradesToEmptyPin() { // valid empty pin instead of NPE-ing and aborting the whole metadata query (CI 973411 test_mysql_mtmv // collateral). MUTATION: dropping the null-connector guard in materializeLatest -> NPE -> red. ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); PluginDrivenExternalCatalog droppedCatalog = new TestablePluginCatalog((Connector) null, session); ExternalDatabase db = mockDb("REMOTE_DB"); PluginDrivenMvccExternalTable table = @@ -1363,6 +1366,7 @@ private static Fixture build(List partitions, boolean ti Type partitionColType) { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); ConnectorTableHandle pinnedHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog(metadata, session); @@ -1477,6 +1481,11 @@ public ConnectorSession buildConnectorSession() { return session; } + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + @Override protected List listDatabaseNames() { return Collections.emptyList(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/CatalogStatementTransactionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/CatalogStatementTransactionTest.java new file mode 100644 index 00000000000000..385f43c2caced9 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/CatalogStatementTransactionTest.java @@ -0,0 +1,128 @@ +// 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.plugin; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorWriteOps; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.handle.ConnectorTransaction; +import org.apache.doris.transaction.PluginDrivenTransactionManager; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Pins {@link CatalogStatementTransaction}, the per-statement owner of a plugin-driven write transaction: + * {@link CatalogStatementTransaction#begin} mints the transaction from the statement's shared write-ops and + * registers it with the manager, and the statement-end backstop + * ({@link CatalogStatementTransaction#finalizeAtStatementEnd}) rolls back only a genuinely orphaned + * transaction (mid-flight abort) and NEVER undoes one the executor already committed or rolled back. + */ +public class CatalogStatementTransactionTest { + + private static ConnectorTransaction tx(long id) { + ConnectorTransaction tx = Mockito.mock(ConnectorTransaction.class); + Mockito.when(tx.getTransactionId()).thenReturn(id); + return tx; + } + + private static ConnectorWriteOps writeOpsReturning(ConnectorTransaction tx) { + ConnectorWriteOps ops = Mockito.mock(ConnectorWriteOps.class); + Mockito.when(ops.beginTransaction(Mockito.any(), Mockito.any())).thenReturn(tx); + return ops; + } + + private static CatalogStatementTransaction holder(ConnectorTransaction tx, PluginDrivenTransactionManager mgr) { + return new CatalogStatementTransaction(writeOpsReturning(tx), Mockito.mock(ConnectorSession.class), mgr); + } + + @Test + public void beginMintsFromWriteOpsAndRegistersWithManager() { + // begin() opens the transaction from the statement's ONE shared metadata (writeOps) and registers it, so + // the write inherits the read arm's client/ops and the BE RPC can look it up by id. MUTATION: not + // registering -> isActive false -> red. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80001L); + CatalogStatementTransaction holder = holder(tx, mgr); + + ConnectorTransaction opened = holder.begin(new ConnectorTableHandle() { }); + + Assertions.assertSame(tx, opened, "begin returns the transaction minted from the shared write-ops"); + Assertions.assertEquals(80001L, holder.getTransactionId(), "the holder stamps the connector txn id"); + Assertions.assertTrue(mgr.isActive(80001L), "the transaction is registered active with the manager"); + } + + @Test + public void finalizeRollsBackAnOrphanedTransaction() { + // A statement aborted mid-flight leaves the transaction active (the executor reached neither commit nor + // rollback). The statement-end backstop rolls it back. MUTATION: skipping the rollback -> the orphan + // leaks its resources -> this verify fails. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80002L); + CatalogStatementTransaction holder = holder(tx, mgr); + holder.begin(new ConnectorTableHandle() { }); + + holder.finalizeAtStatementEnd(); + + Mockito.verify(tx).rollback(); + Assertions.assertFalse(mgr.isActive(80002L), "the orphan is deregistered after the backstop rollback"); + } + + @Test + public void finalizeNeverUndoesACommittedTransaction() throws Exception { + // THE safety property: on the normal path the executor commits (removing the txn from the manager), so + // the statement-end backstop must find nothing active and NOT roll back -- otherwise it would undo a + // durably committed write. MUTATION: dropping the isActive guard -> finalize rolls back a committed txn. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80003L); + CatalogStatementTransaction holder = holder(tx, mgr); + holder.begin(new ConnectorTableHandle() { }); + mgr.commit(80003L); // the executor's onComplete path finished it + + holder.finalizeAtStatementEnd(); + + Mockito.verify(tx).commit(); + Mockito.verify(tx, Mockito.never()).rollback(); + } + + @Test + public void finalizeIsANoOpAfterRollback() throws Exception { + // The executor's onFail already rolled back; the backstop must be idempotent -- roll back once, not + // twice. MUTATION: dropping the isActive guard -> a second rollback -> red. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + ConnectorTransaction tx = tx(80004L); + CatalogStatementTransaction holder = holder(tx, mgr); + holder.begin(new ConnectorTableHandle() { }); + mgr.rollback(80004L); // the executor's onFail path finished it + + holder.finalizeAtStatementEnd(); + + Mockito.verify(tx, Mockito.times(1)).rollback(); + } + + @Test + public void finalizeIsANoOpWhenNoTransactionWasEverOpened() { + // The empty-insert path never calls begin(); a stray finalize must not touch the manager or NPE. + PluginDrivenTransactionManager mgr = new PluginDrivenTransactionManager(); + CatalogStatementTransaction holder = holder(tx(80005L), mgr); + + Assertions.assertEquals(CatalogStatementTransaction.INVALID_TXN_ID, holder.getTransactionId()); + Assertions.assertDoesNotThrow(holder::finalizeAtStatementEnd); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogDdlRoutingTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogDdlRoutingTest.java index e423aeee5645f7..3f7a262680fcf2 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogDdlRoutingTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogDdlRoutingTest.java @@ -37,6 +37,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.ddl.BranchChange; import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; @@ -99,6 +100,7 @@ public void setUp() { connector = Mockito.mock(Connector.class); metadata = Mockito.mock(ConnectorMetadata.class); session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); // Construct with the real Env singleton (the constructor is Env-safe), then @@ -1352,6 +1354,11 @@ public ConnectorSession buildConnectorSession() { return sessionMock; } + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + @Override public ExternalDatabase getDbNullable(String dbName) { return dbNullableResult; diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogSessionBypassTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogSessionBypassTest.java index 3e2569d65db968..04b1eaf3ce0435 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogSessionBypassTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalogSessionBypassTest.java @@ -64,6 +64,8 @@ public void bypassesSharedCachesForCapableConnectorCarryingCredential() { "session=user + credential must bypass the shared table-name cache (per-user metadata)"); Assertions.assertTrue(userSession.shouldBypassDbNameCache(credentialed()), "session=user + credential must bypass the shared db-name cache (per-user metadata)"); + Assertions.assertTrue(userSession.shouldBypassSchemaCache(credentialed()), + "session=user + credential must bypass the shared schema cache (list != load metadata disclosure)"); } @Test @@ -76,9 +78,12 @@ public void keepsSharedCachesWhenNoCredentialPresent() { // bypassing into a per-user read that has no identity to authorize with. Assertions.assertFalse(userSession.shouldBypassTableNameCache(SessionContext.empty())); Assertions.assertFalse(userSession.shouldBypassDbNameCache(SessionContext.empty())); + Assertions.assertFalse(userSession.shouldBypassSchemaCache(SessionContext.empty())); Assertions.assertFalse(userSession.shouldBypassTableNameCache(null), "a null session context must not bypass (no credential to key a per-user read)"); Assertions.assertFalse(userSession.shouldBypassDbNameCache(null)); + Assertions.assertFalse(userSession.shouldBypassSchemaCache(null), + "a null session context must not bypass the schema cache either"); } @Test @@ -89,5 +94,6 @@ public void neverBypassesForNonUserSessionConnectorEvenWithCredential() { Assertions.assertFalse(plain.shouldBypassTableNameCache(credentialed())); Assertions.assertFalse(plain.shouldBypassDbNameCache(credentialed())); + Assertions.assertFalse(plain.shouldBypassSchemaCache(credentialed())); } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabaseTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabaseTest.java index c7dcae846eaa51..d1b3390b553f61 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabaseTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalDatabaseTest.java @@ -21,6 +21,7 @@ import org.apache.doris.connector.api.ConnectorDatabaseMetadata; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.datasource.ExternalCatalog; import org.junit.jupiter.api.Assertions; @@ -45,9 +46,11 @@ private static PluginDrivenExternalCatalog catalogReturning(ConnectorDatabaseMet Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); return catalog; } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTablePartitionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTablePartitionTest.java index 7d3e344ddfed75..d6be2c1bad6acd 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTablePartitionTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTablePartitionTest.java @@ -27,6 +27,7 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorTableHandle; @@ -126,6 +127,7 @@ public void testGetNameToPartitionItemsBuildsFromConnectorByRemoteNames() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("max_compute", metadata, session); ExternalDatabase db = mockDb("REMOTE_DB"); @@ -155,7 +157,7 @@ public void testGetNameToPartitionItemsEmptyWhenNotPartitioned() { Collections.emptyList(), Collections.emptyList()); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); TestablePluginCatalog catalog = new TestablePluginCatalog( - "max_compute", metadata, Mockito.mock(ConnectorSession.class)); + "max_compute", metadata, noneScopedSession()); PluginDrivenExternalTable table = tableWithCacheValue( cacheValue, catalog, mockDb("REMOTE_DB"), "REMOTE_TBL"); @@ -169,6 +171,7 @@ public void testGetNameToPartitionItemsEmptyWhenNotPartitioned() { public void testInitSchemaExtractsPartitionColumnsMappingRemoteNames() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("max_compute", metadata, session); ExternalDatabase db = mockDb("REMOTE_DB"); @@ -209,6 +212,7 @@ public void testInitSchemaExtractsPartitionColumnsMappingRemoteNames() { public void testInitSchemaNoPartitionsWhenPropAbsent() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("max_compute", metadata, session); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) @@ -274,6 +278,14 @@ public void testGetTablePropertiesEmptyWhenConnectorEmitsNone() { // ==================== helpers ==================== + /** A ConnectorSession mock that reports the off-context NONE statement scope (matches the real + * interface default), so the PluginDrivenMetadata funnel does not NPE on getStatementScope(). */ + private static ConnectorSession noneScopedSession() { + ConnectorSession s = Mockito.mock(ConnectorSession.class); + Mockito.when(s.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + return s; + } + private static ConnectorPartitionInfo partition(String name, String year, String month) { Map values = new LinkedHashMap<>(); values.put("YEAR", year); @@ -305,7 +317,7 @@ private static List columnNames(List columns) { private static PluginDrivenExternalTable tableWithCacheValue(SchemaCacheValue cacheValue) { return tableWithCacheValue(cacheValue, new TestablePluginCatalog("max_compute", Mockito.mock(ConnectorMetadata.class), - Mockito.mock(ConnectorSession.class)), + noneScopedSession()), mockDb("REMOTE_DB"), "REMOTE_TBL"); } @@ -376,6 +388,11 @@ public ConnectorSession buildConnectorSession() { return session; } + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + @Override protected List listDatabaseNames() { return Collections.emptyList(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableRowCountTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableRowCountTest.java index 36fead68e1b13a..f9e6e234fc98bd 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableRowCountTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableRowCountTest.java @@ -23,6 +23,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorTableStatistics; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.datasource.ExternalDatabase; @@ -223,6 +224,7 @@ private static PluginDrivenExternalTable tableWith(Optional schema, List partitionColumnIndexes) { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) .thenReturn(Optional.of(handle)); @@ -264,6 +266,7 @@ private static PluginDrivenExternalTable tableReturning( Optional stats, List schema) { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); if (stats == null) { Mockito.when(metadata.getTableHandle(session, "REMOTE_DB", "REMOTE_TBL")) .thenReturn(Optional.empty()); @@ -325,6 +328,11 @@ public ConnectorSession buildConnectorSession() { return session; } + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + @Override protected List listDatabaseNames() { return Collections.emptyList(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java index 62d93b6c46f620..c08cf81b13472a 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableTest.java @@ -25,6 +25,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.ConnectorViewDefinition; @@ -85,6 +86,17 @@ public void clearCtx() { ConnectContext.remove(); } + /** + * A ConnectorSession mock that reports the off-context {@link ConnectorStatementScope#NONE} scope, so the + * {@code PluginDrivenMetadata.get(session, connector)} funnel runs {@code connector.getMetadata(session)} on + * every call instead of NPE-ing on a null scope (a plain mock's getStatementScope() would return null). + */ + private static ConnectorSession noneScopedSession() { + ConnectorSession s = Mockito.mock(ConnectorSession.class); + Mockito.when(s.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); + return s; + } + // ==================== §4.4 W3: per-handle write-admission capability probes ==================== /** @@ -105,7 +117,9 @@ private static PluginDrivenExternalTable capabilityTable(boolean handlePresent, Mockito.when(connector.requiresMaterializeStaticPartitionValues(Mockito.any())).thenReturn(true); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); - Mockito.when(catalog.buildConnectorSession()).thenReturn(Mockito.mock(ConnectorSession.class)); + ConnectorSession session = noneScopedSession(); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); Deencapsulation.setField(table, "catalog", catalog); return table; @@ -164,7 +178,9 @@ private static PluginDrivenExternalTable writeTargetTable(ConnectorTableHandle r Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); - Mockito.when(catalog.buildConnectorSession()).thenReturn(Mockito.mock(ConnectorSession.class)); + ConnectorSession session = noneScopedSession(); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); Deencapsulation.setField(table, "catalog", catalog); return table; @@ -204,7 +220,9 @@ private static PluginDrivenExternalTable syntheticPredicateTable(ConnectorTableH Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); - Mockito.when(catalog.buildConnectorSession()).thenReturn(Mockito.mock(ConnectorSession.class)); + ConnectorSession session = noneScopedSession(); + Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); Deencapsulation.setField(table, "catalog", catalog); return table; @@ -260,6 +278,7 @@ private static PluginDrivenExternalTable pluginTable(List synth Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) .thenReturn(handlePresent ? Optional.of(handle) : Optional.empty()); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getWritePlanProvider()).thenReturn(writeProviderPresent ? provider : null); // Production now selects the write provider per-handle; a plain Mockito mock does not run the interface @@ -270,6 +289,7 @@ private static PluginDrivenExternalTable pluginTable(List synth PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); @@ -619,6 +639,7 @@ private static PluginDrivenExternalTable pluginViewTable(Set inv.getArgument(3)); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); ExternalDatabase db = Mockito.mock(ExternalDatabase.class); @@ -825,6 +854,7 @@ public void initSchemaUsesTableHandlePathForNonView() { PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); Deencapsulation.setField(table, "catalog", catalog); @@ -851,6 +881,7 @@ public void systemTableOverridesResolveIsViewToFalse() { Mockito.when(metadata.viewExists(Mockito.any(), Mockito.anyString(), Mockito.anyString())) .thenReturn(true); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getCapabilities()).thenReturn(EnumSet.of(ConnectorCapability.SUPPORTS_VIEW)); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); @@ -859,6 +890,7 @@ public void systemTableOverridesResolveIsViewToFalse() { PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); PluginDrivenSysExternalTable sys = Mockito.mock(PluginDrivenSysExternalTable.class, Mockito.CALLS_REAL_METHODS); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMetadataTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMetadataTest.java new file mode 100644 index 00000000000000..99bf828099a042 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMetadataTest.java @@ -0,0 +1,162 @@ +// 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.plugin; + +import org.apache.doris.connector.ConnectorStatementScopeImpl; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Pins the engine-side metadata funnel {@link PluginDrivenMetadata}: within one statement it hands every caller + * the SAME memoized {@link ConnectorMetadata} per catalog (so read / scan / DDL / MVCC share one), isolates + * distinct catalogs, runs the factory on every call under {@link ConnectorStatementScope#NONE} (offline / + * byte-identical to today), and lets the scope close the memoized instance exactly once at statement end. + */ +public class PluginDrivenMetadataTest { + + private static ConnectorSession session(long catalogId, ConnectorStatementScope scope) { + ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getCatalogId()).thenReturn(catalogId); + Mockito.when(session.getStatementScope()).thenReturn(scope); + return session; + } + + private static ConnectorSession session(long catalogId, ConnectorStatementScope scope, String user) { + ConnectorSession session = session(catalogId, scope); + Mockito.when(session.getUser()).thenReturn(user); + return session; + } + + private static Connector countingConnector(AtomicInteger builds) { + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenAnswer(inv -> { + builds.incrementAndGet(); + return Mockito.mock(ConnectorMetadata.class); + }); + return connector; + } + + @Test + public void memoizesOneMetadataPerStatement() { + // The read/scan/DDL/MVCC resolvers of one statement all route through the funnel and must collapse onto a + // single getMetadata build and share the one instance. MUTATION: not memoizing -> two builds / two + // instances -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + ConnectorSession session = session(7L, scope); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata first = PluginDrivenMetadata.get(session, connector); + ConnectorMetadata second = PluginDrivenMetadata.get(session, connector); + + Assertions.assertSame(first, second, "one statement + one catalog -> one shared metadata instance"); + Assertions.assertEquals(1, builds.get(), "connector.getMetadata is built once per statement"); + } + + @Test + public void noneBuildsMetadataEachCall() { + // No live statement scope (offline / tests / no ConnectContext): the funnel must degrade to today's + // behavior -> a fresh getMetadata every call, byte-identical to pre-funnel. MUTATION: NONE memoizing -> + // one build -> red. + ConnectorSession session = session(7L, ConnectorStatementScope.NONE); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata a = PluginDrivenMetadata.get(session, connector); + ConnectorMetadata b = PluginDrivenMetadata.get(session, connector); + + Assertions.assertNotSame(a, b, "NONE memoizes nothing -> a fresh metadata each call"); + Assertions.assertEquals(2, builds.get(), "NONE runs the factory (getMetadata) every call"); + } + + @Test + public void differentCatalogIdIsolates() { + // A cross-catalog statement (e.g. a MERGE reading two catalogs) resolves each catalog's metadata + // independently, because the memo key carries the catalog id. MUTATION: dropping the catalog id from the + // key -> the two catalogs collide onto one instance -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata c1 = PluginDrivenMetadata.get(session(1L, scope), connector); + ConnectorMetadata c2 = PluginDrivenMetadata.get(session(2L, scope), connector); + + Assertions.assertNotSame(c1, c2, "distinct catalogs are isolated"); + Assertions.assertEquals(2, builds.get(), "one build per distinct catalog"); + } + + @Test + public void sameIdentityReusesTheMemoizedInstance() { + // The write arm now reuses the read arm's memoized instance. Within one statement read and write are the + // same user, so the identity guard is satisfied and the instance is shared. MUTATION: guard throwing on a + // matching identity -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + ConnectorMetadata first = PluginDrivenMetadata.get(session(7L, scope, "alice"), connector); + ConnectorMetadata second = PluginDrivenMetadata.get(session(7L, scope, "alice"), connector); + + Assertions.assertSame(first, second, "same user in one statement -> one shared instance"); + Assertions.assertEquals(1, builds.get(), "built once"); + } + + @Test + public void differentIdentityReusingTheInstanceFailsLoud() { + // A session=user connector bakes the querying user's delegated credential into the instance at build time. + // Reusing that instance under a second identity would execute one user's operation with another's + // credentials, so the funnel fails loud rather than serving it. MUTATION: dropping the identity guard -> + // the mismatched reuse silently returns the first user's instance -> red. + ConnectorStatementScope scope = new ConnectorStatementScopeImpl(); + AtomicInteger builds = new AtomicInteger(); + Connector connector = countingConnector(builds); + + PluginDrivenMetadata.get(session(7L, scope, "alice"), connector); + + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, + () -> PluginDrivenMetadata.get(session(7L, scope, "bob"), connector)); + Assertions.assertTrue(ex.getMessage().contains("identity mismatch"), + "message names the identity mismatch: " + ex.getMessage()); + } + + @Test + public void closeAllClosesTheMemoizedMetadataOnce() throws Exception { + // The statement's one memoized ConnectorMetadata (which is Closeable) is closed deterministically at + // statement end, exactly once even though the engine fires closeAll from more than one locus (query-finish + // callback + prepared-statement reset). MUTATION: closeAll not idempotent -> close() called twice -> red. + ConnectorStatementScopeImpl scope = new ConnectorStatementScopeImpl(); + ConnectorSession session = session(7L, scope); + ConnectorMetadata md = Mockito.mock(ConnectorMetadata.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(md); + + PluginDrivenMetadata.get(session, connector); + scope.closeAll(); + scope.closeAll(); + + Mockito.verify(md, Mockito.times(1)).close(); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java index 23f0ce9e8812f3..ef2c02c847229b 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProviderSelectionTest.java @@ -66,4 +66,47 @@ public void resolvesProviderForCurrentHandle() { Assertions.assertSame(hiveProvider, Deencapsulation.invoke(node, "resolveScanProvider"), "after the handle changes the node must resolve the matching provider (per-table routing)"); } + + /** + * Guards that {@link PluginDrivenScanNode#resolveScanProvider()} MEMOIZES the resolved provider for a stable + * {@code currentHandle} so the per-split hot path ({@code getFileCompressType} / {@code getDeleteFiles}) stops + * re-allocating a provider on every split, while still RE-RESOLVING when pushdown/pin refines the handle. + * + *

    WHY this matters (Rule 9): providers are built fresh per call (SPI contract), so before this memo + * every split re-ran {@code connector.getScanPlanProvider(currentHandle)} plus a TCCL swap. A mutant that drops + * the memo (resolves per call) reintroduces that O(splits) allocation; a mutant that never re-resolves sends a + * table to a stale provider after the handle is refined. The call-count assertion pins both: exactly ONE resolve + * per distinct handle. Same {@code CALLS_REAL_METHODS} + {@code Deencapsulation} technique as + * {@link #resolvesProviderForCurrentHandle}.

    + */ + @Test + public void memoizesProviderForStableHandleAndReResolvesOnHandleChange() { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + + ConnectorTableHandle icebergHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorTableHandle hiveHandle = Mockito.mock(ConnectorTableHandle.class); + ConnectorScanPlanProvider icebergProvider = Mockito.mock(ConnectorScanPlanProvider.class); + ConnectorScanPlanProvider hiveProvider = Mockito.mock(ConnectorScanPlanProvider.class); + + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getScanPlanProvider(icebergHandle)).thenReturn(icebergProvider); + Mockito.when(connector.getScanPlanProvider(hiveHandle)).thenReturn(hiveProvider); + Deencapsulation.setField(node, "connector", connector); + + // Stable handle: many resolves (mirroring the per-split getFileCompressType calls) resolve ONCE. + Deencapsulation.setField(node, "currentHandle", icebergHandle); + for (int i = 0; i < 3; i++) { + Assertions.assertSame(icebergProvider, Deencapsulation.invoke(node, "resolveScanProvider"), + "a stable handle must return the memoized provider"); + } + Mockito.verify(connector, Mockito.times(1)).getScanPlanProvider(icebergHandle); + + // Handle refined by pushdown/pin (a NEW handle object): re-resolve exactly once for the new handle. + Deencapsulation.setField(node, "currentHandle", hiveHandle); + for (int i = 0; i < 2; i++) { + Assertions.assertSame(hiveProvider, Deencapsulation.invoke(node, "resolveScanProvider"), + "after the handle changes the node must re-resolve to the matching provider"); + } + Mockito.verify(connector, Mockito.times(1)).getScanPlanProvider(hiveHandle); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysHandleTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysHandleTest.java index 432f2a372d8d51..333e7229b8db7d 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysHandleTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeSysHandleTest.java @@ -23,6 +23,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.SessionContext; @@ -69,6 +70,7 @@ public class PluginDrivenScanNodeSysHandleTest { public void createThreadsSysHandleNotBaseHandleForSysTable() { ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); // Two DISTINCT handles: a base-table handle and the connector's system-table handle. // The base handle stands in for the NORMAL PaimonTableHandle (forceJni=false) that raw // getTableHandle would yield; the sys handle stands in for the force-JNI sys handle. @@ -121,6 +123,7 @@ public void createUsesBaseHandleForNormalTableUnchanged() { // fix is behavior-preserving for normal tables (max_compute/jdbc/etc.). ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorTableHandle baseHandle = Mockito.mock(ConnectorTableHandle.class); TestablePluginCatalog catalog = new TestablePluginCatalog("paimon", metadata, session); @@ -198,6 +201,11 @@ public ConnectorSession buildConnectorSession() { return session; } + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + @Override protected List listDatabaseNames() { return Collections.emptyList(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java index 30a136bf48fcb7..2960a1f5fbfc08 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorAdmissionGateTest.java @@ -23,6 +23,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.WriteOperation; import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; @@ -141,6 +142,9 @@ private static PluginDrivenExternalTable pluginTable(Set ops) { ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + // The write seams now resolve metadata through the per-statement funnel, which reads the session's + // statement scope; offline tests use NONE (a fresh getMetadata per call, byte-identical to pre-funnel). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java index baa59395c8de03..a5abd7bcfb5b49 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorIcebergRowLevelDmlTest.java @@ -27,6 +27,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.WriteOperation; import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; @@ -262,6 +263,9 @@ private static Plugin pluginTable() { ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorSession session = Mockito.mock(ConnectorSession.class); + // The write seams now resolve metadata through the per-statement funnel, which reads the session's + // statement scope; offline tests use NONE (a fresh getMetadata per call, byte-identical to pre-funnel). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransformTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransformTest.java index b9b07ee1e74a97..329534236fef20 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransformTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/IcebergRowLevelDmlTransformTest.java @@ -23,6 +23,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.ConnectorTransaction; @@ -144,6 +145,9 @@ private static PluginDrivenExternalTable pluginTableWithMetadata( Mockito.when(catalog.buildConnectorSession()).thenReturn(session); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(connector.getMetadata(session)).thenReturn(metadata); + // checkMode now resolves metadata through the per-statement funnel, which reads the session's statement + // scope; offline tests use NONE (a fresh getMetadata per call, byte-identical to pre-funnel). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); return table; } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommandPluginDrivenTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommandPluginDrivenTest.java index 1463023182a0ee..3bfb588129443c 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommandPluginDrivenTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowPartitionsCommandPluginDrivenTest.java @@ -25,6 +25,7 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorPartitionInfo; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.ExternalTable; @@ -66,6 +67,7 @@ public void testHandlerRoutesToSpiWithRemoteNames() throws Exception { ShowPartitionsCommand command = new ShowPartitionsCommand(tableName, null, null, -1L, -1L, false); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Connector connector = Mockito.mock(Connector.class); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); @@ -81,6 +83,7 @@ public void testHandlerRoutesToSpiWithRemoteNames() throws Exception { Mockito.doReturn(db).when(catalog).getDbOrAnalysisException("db"); Mockito.doReturn(table).when(db).getTableOrAnalysisException("t"); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(connector.getMetadata(session)).thenReturn(metadata); Mockito.when(metadata.getTableHandle(session, "remote_db", "remote_tbl")) @@ -111,6 +114,7 @@ public void testHandlerEmitsFiveColumnsWhenConnectorDeclaresPartitionStats() thr ShowPartitionsCommand command = new ShowPartitionsCommand(tableName, null, null, -1L, -1L, false); ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Connector connector = Mockito.mock(Connector.class); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); @@ -128,6 +132,7 @@ public void testHandlerEmitsFiveColumnsWhenConnectorDeclaresPartitionStats() thr Mockito.doReturn(db).when(catalog).getDbOrAnalysisException("db"); Mockito.doReturn(table).when(db).getTableOrAnalysisException("t"); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(connector.getMetadata(session)).thenReturn(metadata); // The capability that flips SHOW PARTITIONS to the 5-column rich result (D-045). diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteActionTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteActionTest.java index 51b4495b2df54d..0111ef24649008 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteActionTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteActionTest.java @@ -31,6 +31,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; @@ -496,6 +497,9 @@ private static final class Fixture { @SuppressWarnings("unchecked") Fixture() { props.put("snapshot_id", "200"); + // The funnel PluginDrivenMetadata.get(session, connector) reads session.getStatementScope(); a bare + // Mockito mock returns null (NPE), so pin the interface-default NONE scope (runs getMetadata per call). + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); Mockito.when(connector.getProcedureOps()).thenReturn(procedureOps); // execute() selects the ops per-handle (getProcedureOps(handle)); a Mockito mock does NOT run the @@ -550,6 +554,11 @@ public ConnectorSession buildConnectorSession() { return session; } + @Override + public ConnectorSession buildCrossStatementSession() { + return buildConnectorSession(); + } + @Override protected List listDatabaseNames() { return Collections.emptyList(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriverTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriverTest.java index 6d32290f1cdeba..9a95ae23067e60 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriverTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriverTest.java @@ -26,12 +26,14 @@ import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ConnectorRewriteGroup; import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; import org.apache.doris.connector.api.pushdown.ConnectorPredicate; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.qe.ConnectContext; +import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -40,6 +42,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; /** @@ -123,4 +126,42 @@ public void planRewriteFailureSurfacesAsUserException() { Assertions.assertTrue(ex.getMessage().contains("plan boom"), "the connector failure text must be preserved, got: " + ex.getMessage()); } + + @Test + public void unionSourceFilePathsMergesAllGroupsAndDedupsByPath() { + // STEP 3 registers the UNION of every group's source files in ONE connector call (one planFiles() scan) + // instead of one call per group. Disjoint groups union straight; a path recurring across groups collapses + // to a single entry, so the connector never double-registers a file to delete. MUTATION: unioning only + // the first group (or not deduping) is killed here. + ConnectorRewriteGroup g1 = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/a.parquet", "s3://b/t/b.parquet"), 2, 2048L, 0); + ConnectorRewriteGroup g2 = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/c.parquet"), 1, 1024L, 0); + // Defensive: a path shared with g1 (bin-packing keeps groups disjoint, but the union must still dedup). + ConnectorRewriteGroup g3 = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/a.parquet", "s3://b/t/d.parquet"), 2, 2048L, 0); + + Set union = ConnectorRewriteDriver.unionSourceFilePaths(Arrays.asList(g1, g2, g3)); + + Assertions.assertEquals( + ImmutableSet.of("s3://b/t/a.parquet", "s3://b/t/b.parquet", "s3://b/t/c.parquet", + "s3://b/t/d.parquet"), + union, "the union must contain each distinct source path exactly once across all groups"); + } + + @Test + public void unionSourceFilePathsSkipsEmptyGroupsAndEmptyPlan() { + // An empty group contributes nothing; an all-empty plan unions to the empty set (the connector treats + // that as a no-op registration — the same net state as the former loop making N early-returning calls). + ConnectorRewriteGroup withFiles = new ConnectorRewriteGroup( + ImmutableSet.of("s3://b/t/a.parquet"), 1, 1024L, 0); + ConnectorRewriteGroup empty = new ConnectorRewriteGroup(Collections.emptySet(), 0, 0L, 0); + + Assertions.assertEquals(ImmutableSet.of("s3://b/t/a.parquet"), + ConnectorRewriteDriver.unionSourceFilePaths(Arrays.asList(withFiles, empty)), + "an empty group must not affect the union"); + Assertions.assertTrue( + ConnectorRewriteDriver.unionSourceFilePaths(Collections.emptyList()).isEmpty(), + "an all-empty plan unions to the empty set"); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/MetadataGeneratorPluginDrivenTest.java b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/MetadataGeneratorPluginDrivenTest.java index 6bebc9a5f87884..2af2683bccc5fe 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/tablefunction/MetadataGeneratorPluginDrivenTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/tablefunction/MetadataGeneratorPluginDrivenTest.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; @@ -61,12 +62,14 @@ private TFetchSchemaTableDataResult invokeDeal(PluginDrivenExternalCatalog catal @Test public void testRoutesToSpiWithRemoteNamesAndBuildsRows() throws Exception { ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Connector connector = Mockito.mock(Connector.class); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(connector.getMetadata(session)).thenReturn(metadata); @@ -93,11 +96,13 @@ public void testRoutesToSpiWithRemoteNamesAndBuildsRows() throws Exception { @Test public void testAbsentHandleYieldsEmptyOkResult() throws Exception { ConnectorSession session = Mockito.mock(ConnectorSession.class); + Mockito.when(session.getStatementScope()).thenReturn(ConnectorStatementScope.NONE); Connector connector = Mockito.mock(Connector.class); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.buildConnectorSession()).thenReturn(session); + Mockito.when(catalog.buildCrossStatementSession()).thenReturn(session); Mockito.when(catalog.getConnector()).thenReturn(connector); Mockito.when(connector.getMetadata(session)).thenReturn(metadata); diff --git a/plan-doc/HANDOFF.md b/plan-doc/HANDOFF.md index b6c60fb1c1ff6b..fac08121d87a6f 100644 --- a/plan-doc/HANDOFF.md +++ b/plan-doc/HANDOFF.md @@ -67,3 +67,16 @@ BE 单次启动、优雅退出(`be.out` 仅退出时 LSAN leak summary,零 ` ④ trino 改名 PR 收尾两笔(**需 release note**;BE 未跑全量构建 + fallback 无 e2e); ⑤ 独立任务空间 `plan-doc/hive-catalog-shade-removal/`(**从它自己的 HANDOFF 进**); ⑥ 并发 session 已结项的 QUIC 根治(`ae82ffd2573`)+ 插件包瘦身 Tier A(`dece64b9ff5`)明细。 + +--- + +# 📎 并行独立任务(与上面 CI 线无关):热路径重操作审计(DORIS-27138 问题类) + +> 2026-07-17 独立调研,session 自包含,不影响 CI 线。用户待 review。 + +- 问题类总结(三要素 + A/B/C/D 变体 + 审计清单):`plan-doc/perf-heavy-op-hot-path-problem-class.md` +- **fe-connector-iceberg 审计报告**(23 确认/1 驳回,分 P0/P1/P2 三层七簇):`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17.md` +- 完整证据 JSON(全部调用链+双路对抗验证意见):`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17-findings.json` +- **P0 三簇**:①无 Table 对象缓存,一次规划 3~7 次远程 loadTable;②#64134 planFiles 兜底复活(`IcebergWriterHelper.getFileFormat`,每查询 1-2 次整表扫);③分区表每查询一次 PARTITIONS 元数据表扫描(CACHE-P1 弃二级缓存的代价)。 +- 旁获(与审计无关待单独处理):`CreateDictionaryInfo.validateAndSet:164` 强转 `catalog.Table` ⇒ 外表 CREATE DICTIONARY 必 ClassCastException(功能缺口/潜在 bug)。 +- 下一步(等用户 review 后):其余连接器(hive/paimon/hudi/mc)按同一问题类+同一 workflow 模式逐个审计。 diff --git a/plan-doc/master-todo/README.md b/plan-doc/master-todo/README.md new file mode 100644 index 00000000000000..af9b27f61fd112 --- /dev/null +++ b/plan-doc/master-todo/README.md @@ -0,0 +1,29 @@ +# 📌 Master TODO —— 跨任务 · 需拍板 / 协议演进级的待决大项 + +> **用途**:集中记录那些**已被复核清楚、但暂缓推进**的大项——它们不是普通的"待修 bug",而是需要**用户先拍板**(改变行为语义、或涉及协议演进)、或跨多个任务边界的工程决策。放在这里是为了**不被遗忘**:随时可以拿出来重新评估、立项。 +> +> **收录标准**(满足其一即入): +> 1. 修复会**改变可观察行为/语义**(哪怕功能安全),须用户显式接受; +> 2. 涉及**协议演进**(改 thrift + 改 BE + 跨版本兼容),非纯 FE 回归修复; +> 3. 跨多个任务空间、或收益/代价需要人来权衡后才决定做不做。 +> +> **不收录**:普通可直接做的回归修复 / 性能修复(那些走各自任务空间的 task-list + git)。 +> +> **每项一个独立 md**,含:背景 · 当前代码调用栈与问题 · 方案调用栈与问题 · 示例 · 状态 / 为何需拍板。行号信 HEAD、以 `grep` 为准(文档行号会随代码漂移)。 + +--- + +## 索引 + +| # | 待决项 | 改动层 | 收益 | 为何需拍板 | 状态 | 文档 | +|---|---|---|---|---|---|---| +| 1 | 流式扫描「分片派发」逐分片重建后端分配集 → 微批 | 仅 fe-core 通用框架 | 省流式大扫描规划 CPU(10⁵ 分片≈0.1~0.5s) | **非字节等价**——微批会改变分片落到哪些后端节点(有意的负载均衡语义变更) | ⏳ 待定 | [`streaming-split-dispatch-microbatch.md`](./streaming-split-dispatch-microbatch.md) | +| 2 | 线路上删除文件列表逐分片重复 → 扫描节点级删除文件字典 + 每范围索引 | FE + **thrift 协议 + BE 读取端** | 减发给 BE 的计划体积(删除密集 MOR 表可省几 MB) | **协议演进**——改 BE + 跨版本兼容矩阵,非回归修复 | ⏳ 待定 | [`wire-delete-file-dictionary.md`](./wire-delete-file-dictionary.md) | + +--- + +## 说明 + +- 这两项来自 iceberg 连接器热路径重操作审计的收尾复核(2026-07-19,证据已在 HEAD 上重新核实、均未失效)。同批次的其余低量级项(通用节点路径重复解析 / build-then-discard 分区列)性价比低或碰"fe-core 只减不增"铁律,已暂缓,未单独入册。 +- 若将来推进任一项,按项目惯例走:**先设计 → 用户确认 → 再动代码**;协议演进项(第 2 项)**动 BE/thrift 前必须签字**。 +- 内部关联(审计原始证据 + 逐项调用链):`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17*` 与任务空间 `plan-doc/perf-hotpath-iceberg/`。 diff --git a/plan-doc/master-todo/streaming-split-dispatch-microbatch.md b/plan-doc/master-todo/streaming-split-dispatch-microbatch.md new file mode 100644 index 00000000000000..1897cb3a4d1666 --- /dev/null +++ b/plan-doc/master-todo/streaming-split-dispatch-microbatch.md @@ -0,0 +1,92 @@ +# 待决项 1 —— 流式扫描的「分片派发」每次只送一个分片给后端分配器 + +> **状态**:⏳ 待定(**需用户签"接受后端分配语义变化"**才能立项) +> **改动层**:仅 fe-core 通用框架(`PluginDrivenScanNode` / `SplitAssignment` / `FederationBackendPolicy`),不触 BE、不触 thrift。 +> **影响面**:流式路径由 **iceberg 和 trino 两个连接器**共用——通用改动同时影响两者(不能只改 iceberg,否则违反"通用节点禁按源名分支")。 +> **行号信 HEAD(55087e08d0c 附近,PERF-11 后)**,以 `grep` 为准。 + +--- + +## 背景 + +当一张外表非常大(匹配文件数 ≥ `num_files_in_batch_mode`,默认 1024;实际针对 10 万~100 万分片的扫描),扫描走**流式**模式:不一次性把所有分片算出来堆在 FE 内存里(会 OOM),而是一边惰性读元数据、一边把分片"泵"给调度器,靠背压(队列满就等)把 FE 堆压住。 + +问题不在"读元数据"(那是必要成本),而在**把分片交给"后端分配器"的方式**——分配器(`FederationBackendPolicy`)负责决定"这个分片让哪台 BE 去扫"。 + +--- + +## 当前代码的调用栈与问题 + +``` +startStreamingSplit() PluginDrivenScanNode.java:1600 [单个后台任务] +└─ while (needMoreSplit() && source.hasNext()): :1638 ← 逐个分片的泵循环 + one = [ new PluginDrivenSplit(source.next()) ] :1639-1640 ← 每次只包 1 个分片的 List + splitAssignment.addToQueue(one) :1641 → SplitAssignment.java:143 + └─ synchronized (assignLock) { ... } SplitAssignment.java:148 ← 每分片一次加锁往返 + backendPolicy.computeScanRangeAssignment(one) :153 → FederationBackendPolicy.java:225 + ├─ Collections.shuffle(one, seeded) :228 ← 对 1 个元素洗牌 = 空操作 + ├─ backends = flatten(backendMap) :231-234 ← 把全部 ~100 台 BE 拷进新 List + ├─ new ResettableRandomizedIterator(backends) :235 ← 又拷一遍(默认 ROUND_ROBIN 用不到) + ├─ 选 1 台 BE (ROUND_ROBIN: nextBe++) :269-270 + └─ if (enableSplitsRedistribution) :307 ← 默认 true、无生产关闭途径 + equateDistribution(assignment) :320 + ├─ allNodes 排序 O(B·logB) :329 + └─ 建两个 IndexedPriorityQueue :335-343 ← 对全部 BE 各建一遍堆 +``` + +**问题**:泵循环每吐一个分片,`computeScanRangeAssignment` 就把"和这一个分片无关的固定开销"从头重算一遍——把上百台 BE 拷两遍、建 multimap、洗牌、加锁往返,**还有 `equateDistribution`**:它对全部后端做一次 `O(B·logB)` 排序 + 建两个堆,而且默认恒开(`enableSplitsRedistribution=true`,那个 setter 只有测试在调、生产没有关它的路径)。 + +100 万分片 × 上百台 BE,这些"每分片重建"的操作叠起来是纯 FE 的 CPU + 大量短命对象(GC 压力)。**没有远程 IO**,所以量级有限(10 万分片约 0.1~0.5 秒、100 万分片几秒),但确实是白干——这些固定开销本该一批分片摊一次。 + +> 对照:分区批风格的兄弟路径已经是"整批一起 `addToQueue`"(`PluginDrivenScanNode.java:1570` 附近),非流式的 legacy 路径也是"所有分片一次 `computeScanRangeAssignment`"(`FileQueryScanNode.java:431`)——唯独流式泵是"一次一个"。 + +--- + +## 解决方案的调用栈与问题 + +方向:泵侧**微批**——攒够 K 个(64~256)再一起送。 + +``` +startStreamingSplit() +└─ batch = new ArrayList(K) + while (needMoreSplit() && source.hasNext()): + batch.add(new PluginDrivenSplit(source.next())) + if (batch.size() >= K) { + splitAssignment.addToQueue(batch) ← 一次送 K 个 + batch = new ArrayList(K) + } + if (!batch.isEmpty()) splitAssignment.addToQueue(batch) ← 末批必须 flush,否则丢分片 + splitAssignment.finishSchedule() + └─ computeScanRangeAssignment(batch) ← 从"每分片一次"变"每 K 个一次" +``` + +**方案自身的问题(正是它需要用户签字的原因)**: + +1. **不是字节等价——后端分配结果会变**(关键)。当前"一次一个"下,`Collections.shuffle`(对单元素是空操作)和 `equateDistribution`(单分片没什么可再平衡的)实际都不起作用;一旦变成"一批 K 个",这两步就**开始真正做事**:洗牌重排这 K 个的顺序、`equateDistribution` 在这一批内部把分片从"堆得多的 BE"挪到"堆得少的 BE"。而 `nextBe`、`assignedWeightPerBackend` 是**跨批累积的实例字段**(`FederationBackendPolicy.java:84/88`)。所以**同一批分片最终落到哪些 BE 会和现在不同**。 + - 功能上安全:每个分片仍恰好被分配一次、所有数据都读到,变的只是"哪个分片去哪台机器"——负载均衡的启发式结果(甚至更均衡)。 + - 但它**违反"共享框架热路径须逐字节不变"**纪律,不能声称"透明无感"。且"一次一个"本身是照搬**上游** legacy `doStartSplit`(`:1635-1637` 注释写明),改它属于"在上游基线上演进",不是"修回归"。→ **需要用户签"接受这个负载均衡分配变化"**。 + +2. **正确性不变式要小心**:`needMoreSplit()` 背压要**每批**重新检查(不能攒到一半该停了还继续攒);末批一定要 flush;批边界不能丢/重分片;`source.close()` 仍在 finally 里吞异常。这些可单测。 + +3. **验证**:碰 fe-core → 两段验(iceberg 连接器不依赖 fe-core)。但"省了多少时延"和"分配分布变成什么样"这两个承载性结论**单测测不出来**,要真 BE 分布式跑 ≥1024 文件的流式扫描(iceberg + trino 各一次)才能观测;正确性不变式(不丢不重/背压)可用 mock split source 单测。 + +--- + +## 示例 + +设 100 台 BE、一次流式扫描 50 万分片、微批 K=128: +- **当前**:`computeScanRangeAssignment` 被调 **50 万次**,每次拷 100 台 BE 两遍 + 排序 100·log100 + 建两个堆 + 一次加锁 → 约 50 万次全套固定开销。 +- **微批后**:被调 **≈3900 次**(50 万 / 128),固定开销摊薄到 1/128。 +- **代价示例**:假设分片按 round-robin,当前第 7 个分片去 BE#7、第 8 个去 BE#8……微批后,这 128 个先被洗牌重排、再被 `equateDistribution` 按累积权重挪动,**第 7 个分片可能落到 BE#42**。查询结果完全一样,但"哪台机器扫哪个文件"的分布图变了——这就是需要用户点头的那个"语义变化"。 + +--- + +## 立项前须确认 + +- [ ] 用户签字:**接受微批带来的后端分片分配分布变化**(功能等价、负载均衡启发式变)。 +- [ ] 定 K(批大小)与背压交互;确认微批不改"每分片恰分一次 / 不丢不重"。 +- [ ] 两段验 + 真 BE 分布式 smoke(iceberg + trino 流式各一次)。 + +## 关联 + +审计原始证据 / 逐项调用链:`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17*`(该簇 findings);任务空间 `plan-doc/perf-hotpath-iceberg/`。 diff --git a/plan-doc/master-todo/wire-delete-file-dictionary.md b/plan-doc/master-todo/wire-delete-file-dictionary.md new file mode 100644 index 00000000000000..cf6962a837c06b --- /dev/null +++ b/plan-doc/master-todo/wire-delete-file-dictionary.md @@ -0,0 +1,116 @@ +# 待决项 2 —— 线路上「删除文件列表 + 分区 JSON」被逐分片重复塞进每个范围 + +> **状态**:⏳ 待定(**协议演进:动 BE/thrift 前必须用户签字**) +> **改动层**:FE 发送端(iceberg 连接器侧)+ **thrift 协议(新字段)** + **BE 读取端** + **跨版本兼容矩阵**。 +> **触碰铁律 D**(改 BE + 改协议 = 协议演进,非回归修复)。 +> **仅对 v2+ MOR 表生效**,普通表零影响。 +> **行号信 HEAD(55087e08d0c 附近,PERF-11 后)**,以 `grep` 为准。 + +--- + +## 背景 + +Iceberg 的 v2/v3 表支持"读时合并"(MOR):数据文件旁边挂着**删除文件**(position delete / equality delete / deletion vector),BE 读数据时用它们过滤掉被删的行。FE 规划时把每个数据文件的删除文件列表放进发给 BE 的扫描计划。 + +两个放大点叠加: +- 一个大数据文件会被切成 **k 个字节切片**(每个切片是一个扫描范围 `TFileRangeDesc`),BE 并行读; +- 一个 equality delete 文件常被**很多数据文件共享**(它是分区级/表级的删除)。 + +> 上一步的提交(PERF-11 的 `10b7d29423f`)只优化了 **FE 内存侧**——让同一文件的 k 个切片在 FE 堆里**共享**同一份删除列表对象;**线路(发给 BE 的字节)上的重复没动**,就是本项。 + +--- + +## 当前代码的调用栈与问题 + +``` +createFileRangeDesc(...) FileScanNode (每个切片一次) +└─ setScanParams(rangeDesc, split) PluginDrivenScanNode.java:1693 [每范围] + └─ scanRange.populateRangeParams(fmtDesc, rangeDesc) :1705 → IcebergScanRange.java:330 + fileDesc.setPartitionDataJson(partitionJson) :397 ← 分区 JSON 塞进本范围 + if (v2+): :408 + deleteDescs = new ArrayList(deleteFiles.size()) :413 + for (delete : deleteFiles): :414 ← 每范围 × 每删除文件 + deleteDescs.add(delete.toThrift()) :415 ← 每次新建一个 TIcebergDeleteFileDesc + fileDesc.setDeleteFiles(deleteDescs) :417 ← 整份删除列表塞进"本切片"的范围 + rangeDesc.setTableFormatParams(fmtDesc) :1707 +``` + +thrift 的形状(`gensrc/thrift/PlanNodes.thrift`): +``` +TFileRangeDesc (每范围一个) → table_format_params: TTableFormatFileDesc :565 / :464 +TTableFormatFileDesc → iceberg_params: TIcebergFileDesc +TIcebergFileDesc.delete_files : list :339 ← 删除列表挂在"每范围"里 +TIcebergDeleteFileDesc { path; bounds; field_ids; content; DV偏移; original_path; ... } :315-331 + ← 每条约 100~200 字节(主要是路径字符串) +``` + +BE 侧逐范围 inline 消费(无字典): +``` +IcebergReaderMixin ... 初始化删除文件 be/src/format/table/iceberg_reader_mixin.h:435 + table_desc = get_scan_range().table_format_params.iceberg_params [每范围] + for (desc : table_desc.delete_files): :444 ← 逐范围内联遍历 + 分桶到 position / equality / deletion_vector :445-451 +``` + +**问题**:一个数据文件的完整删除列表 + 分区 JSON 被复制进它**每一个字节切片**的 `TFileRangeDesc`;一个被 M 个数据文件共享的删除文件,在计划里出现 **M×k 次**。大 MOR 扫描的计划体积因此多出 MB 级——FE 序列化付一次、BE 反序列化再付一次。CPU 侧还有 `delete.toThrift()`(`:415`;实现在 `IcebergScanRange.java:681`)每范围重转一遍("第二次转换")。 + +--- + +## 解决方案的调用栈与问题 + +方向:**扫描节点级的删除文件字典 + 每范围索引**(去重)。 + +``` +FE 发送端: + createScanRangeLocations() PluginDrivenScanNode.java:1724 + └─ scanProvider.populateScanLevelParams(params, props) :1732 ← 已存在的"扫描节点级"通用挂钩 + └─ (iceberg 新 override) 把整次扫描的去重删除文件表 + 写进 params.iceberg_delete_dict : list ← 每个删除文件只序列化 1 次 + setScanParams(rangeDesc, split) → populateRangeParams(...) [每范围] + └─ fileDesc.setDeleteFileIndices([3, 7, 12]) ← 每范围只带几个 int 索引,不带完整列表 + +BE 读取端: + iceberg_reader_mixin.h + dict = get_params().iceberg_delete_dict ← 扫描级读一次 + for (i : table_desc.delete_file_indices): ← 索引 → 字典解析 + 分桶(dict[i]) +``` + +关键先例(证明形状可行):`TFileScanRangeParams` 里 paimon 的字段 27/30 就是"扫描节点级、避免每分片重复序列化"(`PlanNodes.thrift:549-556` 注释原话 *"Set at ScanNode level to avoid redundant serialization in each split"*),FE 侧正是通过 `ConnectorScanPlanProvider.populateScanLevelParams`(`:474`;`PaimonScanPlanProvider.java:1355` 已用同法)挂上去的。 + +**方案自身的问题(正是它需要用户签字的原因)**: + +1. **这是协议演进,不是干净的回归修复**。要动**三层**:FE 发送端 + thrift schema(新字段)+ **BE 读取端**(`iceberg_reader_mixin.h` 改成"先按索引查字典再分桶")。触碰"改 BE + 改协议"铁律 D → **必须用户先签字**。 + +2. **跨版本兼容矩阵是最难、也是承载性的一环**。集群里 FE/BE 可能版本不一: + - **老 BE 配新 FE**:老 BE 不认新字典字段,只读 `delete_files`——新 FE 要么继续内联发一份(那就没省字节)、要么按"BE 能力位"判断对方支持才发字典; + - **新 BE 配老 FE**:老 FE 只发 `delete_files`,新 BE 要能回退到内联读。 + - 这套"双发/能力位门控 + 双向回退"是真正的工作量,不能跳过。 + +3. **验证最重**:FE 单测(字典+索引与内联语义等价)+ BE reader 单测(索引解析 + 内联回退)+ 真实 MOR 表 e2e(position/equality/deletion vector 三类删除结果不变)+ **混版本兼容矩阵**(老BE↔新FE、新BE↔老FE 都要正确读到删除行)。 + +4. **一个澄清**:FE 发送端其实**不需要动 fe-core**(`populateScanLevelParams` 这个连接器无关挂钩已存在、paimon 已用同法),所以这块不碰"fe-core 加面"。难点全在 **BE + 兼容**。 + +5. **有个 FE-only「半赢」但基本没用**:因为上一步已让同文件的 k 个切片在 FE 堆里共享同一份删除列表,可顺手把 `toThrift()` 也缓存复用(k→1 次转换、省点 FE CPU/堆)。但 thrift 序列化每遇一次引用**仍会把整个结构完整写一遍**,所以**线路字节一个都不少**——只省 FE 的 CPU/堆,还要给不可变可序列化类加可变缓存,多半不值得单做。 + +--- + +## 示例 + +设一个 MOR 表:一个 equality delete 文件 `del-a.parquet`(路径 + 边界 + field_ids 序列化后约 200 字节),被 1000 个数据文件共享;每个数据文件平均切成 4 个字节切片。 +- **当前**:这一个删除文件被序列化 1000 × 4 = **4000 次** ≈ 800 KB,全是重复的同一份;表里若有几十个这样的共享删除文件 → **几 MB 的重复**塞进发给每台 BE 的计划,FE 建 + BE 解析双向付费。 +- **字典方案后**:`del-a.parquet` 在扫描级字典里**只序列化 1 次**(200 字节);4000 个范围各带一个 4 字节索引 ≈ 16 KB。这一个删除文件从 800 KB 降到 ~16 KB。 +- **代价对照**:换来的是要改 thrift + BE reader + 扛住"新旧 FE/BE 混跑都要正确读到删除行"的兼容矩阵——删错或读漏删除文件 = 查询结果错,所以兼容那一环必须做扎实。 + +--- + +## 立项前须确认 + +- [ ] 用户签字:**同意做协议演进**(改 thrift + 改 BE 读取端)。 +- [ ] 定兼容策略:双发 vs BE 能力位门控;确认老BE↔新FE、新BE↔老FE 双向都能正确读删除。 +- [ ] 定新字段布局:字典挂 `TFileScanRangeParams`(仿 paimon 27/30)+ 每范围索引挂 `TIcebergFileDesc`。 +- [ ] 全套验证:FE 等价单测 + BE reader/回退单测 + 真 MOR e2e(三类删除)+ 混版本矩阵。 + +## 关联 + +审计原始证据 / 逐项调用链:`plan-doc/reviews/perf-audit-fe-connector-iceberg-2026-07-17*`(该簇 findings,自述"协议演进非回归修复");任务空间 `plan-doc/perf-hotpath-iceberg/`。 diff --git a/plan-doc/per-statement-table-owner-port/HANDOFF.md b/plan-doc/per-statement-table-owner-port/HANDOFF.md new file mode 100644 index 00000000000000..81eccb6b6ed800 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/HANDOFF.md @@ -0,0 +1,48 @@ +# 🤝 Session Handoff —— 每语句表加载归属者 · 移植/重构 + +> **滚动文档**:每 session 结束覆盖式更新,只留下一个 session 必须的上下文。 +> 开场必读顺序、模板、铁律见 [`README.md`](./README.md);进度见 [`progress.md`](./progress.md);状态见 [`tasklist.md`](./tasklist.md)。 + +--- + +# 🆕 本轮(2026-07-20 session 8)已完成:**缓存隔离——list≠load 越权修复(RD-4)+ 读写重构主线四步收官** + +## 一句话结果 +- **背景**:某些 iceberg 目录(REST + `iceberg.rest.session=user`)按登录用户逐个鉴权,鉴权发生在**加载表的远端往返里**;而"能列表名"与"能加载表"是两套独立授权。此前若干**跨查询缓存**命中就返回、**不走加载表**→"能列不能载"的用户经缓存读到别人才该见的元数据(真实越权,用户已确认 list≠load)。 +- **grounding 纠偏(对抗验证)**:真正**活跃**的越权只 2 个——iceberg `latestSnapshotCache` + fe-core schema 缓存;`partitionCache`/`formatCache` 靠上游 per-user loadTable 已护(脆弱,非活跃漏);**异构 HMS 网关是"潜在"非"活跃"洞**(兄弟被强制 hms flavor、永不 session=user);原计划"分片 + 传属主标签"两点被证伪。 +- **用户签字(三决策)**:①**禁用路线**(session=user 下把授权敏感缓存置空,非身份分片)——无需新 SPI、**无撤权陈旧窗口**、贴合 Doris 现状(session=user 本就每次现载)、镜像已有 tableCache/commentCache 先例;②**三投影缓存统一禁用**(成"session=user ⇒ iceberg 无活跃跨查询元数据缓存"不变量);③**网关加 fail-loud 守卫**。 +- **已落地(全绿,4 独立 commit)**: + - **4a**(`9f88aa4`)iceberg `latestSnapshotCache`/`partitionCache`/`formatCache` 在 `isUserSessionEnabled()` 置空 + `beginQuerySnapshot` null 回退(`loadLatestSnapshotPin`)+ `invalidate*` 三处 null 守卫。 + - **4b**(`92289e3`)fe-core `shouldBypassSchemaCache(SessionContext)`(默认 false)+ `PluginDrivenExternalCatalog` override(判据同库/表名缓存 bypass)+ `ExternalTable.getSchemaCacheValue()` bypass 现读(覆盖 MVCC latest 路径)。 + - **4c**(`c40af11`)hive `getOrCreateIcebergSibling` 建成兄弟若含 `SUPPORTS_USER_SESSION` 即 fail-loud(今天恒不触发,守未来)。 + - **4d**(`5cb5fb9`)防漂移门禁 `tools/check-authz-cache-sharding.sh`(marker:`authz-cache-session-user-disabled`/`authz-cache-exempt`)+ 自测 + 挂 fe-connector pom validate。 +- **验证**:116 目标单测全绿;checkstyle 0;三门禁 exit 0;**clean-room 对抗复审 0 blocker/major**(安全本体全 clean;仅 2 findings 针对门禁本身,minor 已修=正则放宽覆盖 raw `Cache<`/`LoadingCache<`/静态形态)。 +- **未动**:iceberg 表 side-car(正交);`getIdentityShardKey()` SPI 不新增(禁用路线不需要);hive/paimon/hudi 无 session=user 轴不动。 + +--- + +# ➡️ 下一个 session = **统一补 e2e(读写重构主线的唯一欠账)** + +> 读写重构主线 **RD-1→RD-4 / STEP 1–4 全部完成**(读取键石 + HMS 兄弟扇出 + 写入共用 + 缓存隔离)。剩下的是各步一路"择机统一补"的 e2e 欠账,宜一次补齐。 + +## 第一件事(先读) +1. 读 `progress.md` session 6/7/8 尾(各步欠的 e2e 清单)+ 架构记忆 `hms-iceberg-delegation-needs-e2e`。 +2. 读 `designs/P4-cache-isolation-implementation-design.md` §4 + `designs/P3-write-sharing-implementation-design.md` §4(欠账 e2e 范围)。 + +## e2e 待补清单(落 `regression-test/suites/external_table_p2/refactor_catalog_param`) +- **异构 HMS 网关**(RD-2/RD-3 欠):异构目录跑 INSERT/DELETE/MERGE/ALTER/EXECUTE,断言与独立 iceberg 目录同表同结果(对齐 `hms-iceberg-delegation-needs-e2e`)。 +- **越权**(RD-4 欠):造 can-list-cannot-load 用户,断 REST session=user 目录下命中不泄漏别人的 schema/snapshot/分区。 +- 环境依赖真实 REST catalog(session=user)+ 异构 HMS,需先确认 docker 编排是否支持;不支持则先补编排或标注前置。 + +## 铁律 / 闸门提醒 +- 用户 2026-07-19 已豁免铁律 A(fe-core 只减不增)。仍守:连接器 connector-agnostic、**fe-connector-* 不得 import fe-core**(门禁)、新 SPI 用 `getUser()`/主体字节不解析凭证令牌。 +- **动码前探测并发活动**(git log/status + maven 进程 + 近 90s mtime),发现活跃即停手(`concurrent-sessions-shared-worktree-hazard`)。 +- e2e 沿用"择机统一补",但主线已收官,宜作为独立收尾专项立项。 + +--- + +# 🗂 遗留 / 关联 +- 分期定稿:`designs/expanded-scope-phasing-and-security-decisions.md`(§3 缓存隔离、§4 分期、§6 残留风险)。 +- as-built:`designs/P4-cache-isolation-implementation-design.md`(缓存隔离)、`designs/P3-write-sharing-implementation-design.md`(写入共用)。 +- 门禁:`tools/check-fecore-metadata-funnel.sh`、`tools/check-connector-imports.sh`、`tools/check-authz-cache-sharding.sh`(新)。 +- 架构记忆:`iceberg-table-resolution-cache-scoping`、`catalog-spi-session-user-no-live-crossquery-cache`(新)、`hms-iceberg-delegation-needs-e2e`、`catalog-spi-plugin-tccl-classloader-gotcha`。 diff --git a/plan-doc/per-statement-table-owner-port/README.md b/plan-doc/per-statement-table-owner-port/README.md new file mode 100644 index 00000000000000..41c6fdaab3dbf9 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/README.md @@ -0,0 +1,88 @@ +# 📦 任务空间 —— 把「每语句表加载归属者」范式从 iceberg 移植到其它连接器 + +> **独立任务空间**。目标:把 iceberg 上已落地的「一条语句里同一张表只加载一次、读/扫描/写共享」范式(PERF-07 蓝本),移植到其它 catalog-backed 连接器。 +> **重要前提**:这套范式依赖的**中性 fe-core / fe-connector-api 地基已经就位**(PERF-07 建)——移植是**纯连接器侧工作,无需再改 fe-core**。 +> 开场必读顺序、单连接器立项流程、约束铁律见下。协作规范沿用 [`../AGENT-PLAYBOOK.md`](../AGENT-PLAYBOOK.md)。 + +--- + +## 🚩 一句话背景 + +新框架下,一条 DML(尤其 DELETE/MERGE)里同一张表会被**反复远端加载**:读元数据、扫描规划、写成形、开事务各自 `loadTable`,最糟一条语句 3~5 次。iceberg 已通过 PERF-07 修掉(读/扫描/写/beginWrite 共享同一「每语句表加载归属者」,整条语句一张表只加载一次)。本空间把同一范式推广到其它同样"metastore/catalog 反复加载同表"的连接器。 + +> ⚠️ 诚实定位(对齐 iceberg 蓝本):这类改动**性能收益有限**(写侧 ≈0,读侧靠既有缓存已覆盖一部分),**主价值 = 架构连贯 + 删重复加载/单例/胖句柄**。是否对某连接器值得做,取决于它真实的多载程度——**逐连接器复核后再定,不是照单全上**。 + +--- + +## 🧱 已就位的可复用地基(**勿再改,直接复用**) + +PERF-07 已在 fe-core / fe-connector-api 落地一套**连接器无关**的每语句作用域基建(commit `97bdcd6bdbe`): + +| 组件 | 位置 | 作用 | +|---|---|---| +| `ConnectorStatementScope`(SPI + `NONE`) | `fe-connector-api/.../connector/api/ConnectorStatementScope.java` | 中性接口:` T computeIfAbsent(String key, Supplier)`;`NONE`=不缓存(逐次加载),离线/无上下文默认 | +| `ConnectorSession.getStatementScope()` | `fe-connector-api/.../connector/api/ConnectorSession.java` | 默认方法→`NONE`;连接器经它够到每语句作用域 | +| `ConnectorStatementScopeImpl` | `fe-core`(CHM 背书) | 生产实现,挂 `StatementContext` | +| `StatementContext` 懒建字段 + `resetConnectorStatementScope()` | `fe-core` | 唯一贯穿整条语句的对象;不在 close/release 清、随 GC | +| `ConnectorSessionImpl`/`Builder` **构造期捕获** | `fe-core` | 因扫描流式/分批 off-thread 复用同一 session、实时读 thread-local 会失效,故构造期捕获作用域引用 | +| `ExecuteCommand` 一行重置 | `fe-core` | 预编译 EXECUTE 复用同一 `StatementContext`,每执行重置作用域 | + +**移植一个连接器 = 只写连接器侧代码**(不再碰 fe-core,即不再触"fe-core 只减不增"铁律 A)。 + +--- + +## 🧩 连接器侧移植模板(以 `IcebergStatementScope` 为范本) + +对每个目标连接器,镜像 iceberg 的做法(`fe-connector-iceberg/.../IcebergStatementScope.java` 是唯一现成范本): + +1. **加连接器私有 `XStatementScope` helper**:`sharedTable(session, db, tbl, loader)`,键形如 `x.table:catalogId:db:tbl:queryId`;`session==null`(NONE)时逐次加载(等价无缓存)。值存 RAW 表对象(fe-core 不认连接器类型,保持 connector-agnostic)。 +2. **四处表解析全走 `sharedTable`**:读元数据 / 扫描规划 / 写成形 / `beginWrite`——同一语句同一表命中同一次加载。每处 loader 各保留原授权(`newXBackedOps(session)`,一语句=一用户=一凭证)。 +3. **(若有)拆胖句柄**:若该连接器像 iceberg 那样在 handle 上挂了 `resolvedTable` 之类的"同 handle 内 memo",评估是否随之下沉到作用域、让句柄回归纯坐标。**没有就不做**(surgical)。 +4. **(若有)把每语句暂存下沉到作用域**:iceberg 把删除清单暂存(`IcebergRewritableDeleteStash`)从单例下沉到作用域同键 map 并整删该类。目标连接器若有类似"扫描填、写读"的跨臂暂存,同样下沉;没有就跳过。 +5. **响亮失败守卫**:若存在"作用域缺失=静默错误"的路径(iceberg 的 v3 行级 DML + NONE),加 fail-loud(NONE 下抛,消息含 "per-statement scope")。生产恒有 `StatementContext`,仅离线触发。 + +**测试守门**(镜像 iceberg): +- 每语句加载计数守门:读+扫描(+写)共享作用域 → 远端 `loadTable` 计数 **1**;对照 NONE → N。 +- 作用域隔离:同键读写同实例 / 跨 queryId 隔离(预编译重执行)/ 跨 catalogId 隔离(跨-catalog 语句)/ NONE 逐次。 +- e2e:留连接器进入 `SPI_READY_TYPES` 的切换阶段统一补(对齐 iceberg 的 e2e 欠账惯例)。 + +--- + +## 🎯 候选连接器(2026-07-19 初摸,**范围待下 session 复核确认**) + +| 连接器 | 写路径 | `loadTable`/`getTable` 触点 | 候选度 | 备注(待 recon 核实) | +|---|---|---|---|---| +| **paimon** | 有(MTMV/写;`getWritePlanProvider` 未在连接器层 override,写路径结构待确认) | **4 文件**(最多) | **高** | 读侧多载最像 iceberg;`fe-connector-cache` 已复制框架副本 | +| **hive / hms** | 有(`HiveWritePlanProvider`) | 3 文件 | **中-高** | plain-hive 读写活;hms 网关委派 iceberg/hudi 兄弟(休眠,未进 `SPI_READY_TYPES`);**网关按 handle 选 sibling 的特殊性要单独设计** | +| **hudi** | 有(`HudiConnectorMetadata` 写面) | 1 文件 | **中** | 读为主(MTMV 新鲜度已提升);写臂/多载程度待确认 | +| maxcompute / es / jdbc / trino | 部分有写 | **0**(不走 metastore `loadTable` fan-out) | **低 / 大概率不适用** | 解析模型不同(表对象连接器侧缓存 / 无每语句重载);下 session 确认后**排除**居多 | + +> **第一步不是动码,是逐连接器 recon**:确认每个候选真实的"一条语句加载同表几次"、现有缓存覆盖哪段、是否有胖句柄/跨臂暂存。复核可能把某连接器**判为不必做**(如 iceberg PERF-05/10 就被复核缩小/暂缓过)。 + +--- + +## 🔁 单连接器立项流程(一次一个连接器,对齐 `step-by-step-fix`) + +1. **recon(动码前)**:grep 该连接器读/扫描/写/beginWrite 的表解析调用链,数"一条 DML 加载同表几次"、现有缓存边界、有无胖句柄/跨臂暂存。产出该连接器的现状图。 +2. **写设计** `designs/PORT--design.md`:Problem / 现状调用链 / 移植方案(按上面 5 步模板裁剪)/ 与 iceberg 蓝本差异 / Risk / Test Plan(含加载计数守门)。 +3. **设计红队**(对抗 review,clean-room 偏好):至少一个独立视角挑刺——重点核**授权/凭证隔离**(作用域跨用户是否泄漏,参照 iceberg 的 session=user/vended 判定)与**快照/OCC 一致性**。 +4. **实现**:纯连接器侧、最小改动、镜像 `IcebergStatementScope`。 +5. **验证**:连接器 UT 全绿 + 加载计数守门;纯连接器改动**免 fe-core 两段验**(地基已在)。 +6. **独立 commit** + **写小结** `designs/PORT--summary.md` + 勾 `tasklist.md` + 追加 `progress.md` + 覆盖 `HANDOFF.md`。 + +--- + +## 🧱 约束铁律 + +1. **地基勿再改**:`ConnectorStatementScope`/`getStatementScope()`/`ConnectorStatementScopeImpl`/`StatementContext` 已就位,移植**只写连接器侧**——不再碰 fe-core(不重新触铁律 A)。若某连接器暴露地基缺口,先停手交 review,别顺手改 fe-core。 +2. **作用域跨用户即泄漏**:作用域按 `catalogId+queryId`(+db/tbl)建键、存 RAW、随 session 生死;但**缓存命中会绕过 per-user loadTable 里的授权**——凡有 `session=user`/凭证语义的连接器,复核作用域共享是否安全(参照 iceberg 判据:授权发生在 load 调用里,缓存命中绕过它=元数据泄漏)。 +3. **连接器不解析属性 / 通用节点 connector-agnostic**:沿用本项目一贯铁律;作用域值为 `Object`,fe-core 不认连接器类型。 +4. **surgical**:模板 5 步里"拆胖句柄""下沉暂存"仅当该连接器真有对应物才做;没有就不做。 + +--- + +## 🔗 与 iceberg 蓝本的关系 + +- **权威范本**:`plan-doc/perf-hotpath-iceberg/designs/FIX-PERF-07-unified-per-statement-table-owner-{design,summary}.md`;架构结论落 memory `iceberg-table-resolution-cache-scoping`。 +- **代码范本**:`fe-connector-iceberg` 的 `IcebergStatementScope` + 四处 `resolveTable*` 走它 + fail-loud(commit `ea7fd1f6e7a`)。 +- 本空间是那套范式的**推广执行区**,与 `perf-hotpath-iceberg` 任务空间平行、不混流。 diff --git a/plan-doc/per-statement-table-owner-port/designs/C3-read-reroute-verified-checklist.md b/plan-doc/per-statement-table-owner-port/designs/C3-read-reroute-verified-checklist.md new file mode 100644 index 00000000000000..c52919558bdb74 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/C3-read-reroute-verified-checklist.md @@ -0,0 +1,100 @@ +# C3 读取侧改道 —— 逐缝核对清单(动手前,行号已按当前代码校准) + +> 承接 `P1-implementation-design.md` §2/§3 与 `expanded-scope-phasing-and-security-decisions.md` §2。 +> 本文是 C3(读取侧改道 + 后台读穿纠正)**动手前的核对关**产物:把设计里的改道表逐条对当前代码行号重新核实(会漂移),并经一轮对抗式复核(workflow `wf_6e2967a9-1a2`,10 agents:7 census reader + 3 adversarial verifier)。**本文不动代码。** + +--- + +## ✅ 实施状态(2026-07-19 session 4) + +已全部落地并验证:`buildCrossStatementSession` 助手 + 9 处 NONE 读穿(含名字映射两缝,含 fetchRowCount ANALYZE 修复)+ 49 处改道 + 扫描节点 `cachedMetadata`/`metadata()`。测试适配 11 文件 + 新守门 `ConnectorSessionImplTest.explicitNoneStatementScopeWinsOverLiveContext`。验证:目标 247 测全绿 + checkstyle 0 + 主编译 SUCCESS。**剩防漂移门禁**(读取键石收官,见 HANDOFF)。 + +## 0. 结论一句话 + +- fe-core 里连接器 `getMetadata(` 工厂缝 = **66 处**(80 raw grep 减 9 处测试静态 + 4 处漏斗自身 + 1 处 `RuntimeProfile.node.getMetadata()`)。**完整切成四类,相加正好 66,无遗漏/无重叠/无未分类缝。** +- 全部设计命名的行号**零漂移**(例外:扫描节点 9 处 + 尾部两处漂 +13,纯位置漂移,方法未变)。 +- 揪出两处需拍板的偏差(§4)。 + +## 1. 四类分区(66 = 49/51 改道 + 7/9 读穿 + 8 写延后 + 4 漏斗自身在 66 之外) + +> 注:4 处漏斗自身(`PluginDrivenMetadata` 内 3 javadoc + 1 factory lambda)不计入 66。 + +### A. 改道进漏斗(`connector.getMetadata(session)` → `PluginDrivenMetadata.get(session, connector)`) + +| 文件 | 缝 | 当前行 | 线程 | +|---|---|---|---| +| PluginDrivenExternalCatalog(DDL 19 + tableExist 1 = 20 处) | createTable/createDb/dropDb/dropTable/renameTable/truncateTable/addColumn(s)/dropColumn/renameColumn/modifyColumn/reorderColumns/createOrReplaceBranch/createOrReplaceTag/dropBranch/dropTag/addPartitionField/dropPartitionField/replacePartitionField + tableExist | 332,422,501,550,598,670,714,807,823,838,853,868,884,915,931,947,963,988,1003,1018 | on-thread | +| PluginDrivenExternalTable(读 12 处) | getSyntheticScanPredicates(159)/resolveWriteCapabilityHandle(189,读——探写能力但读元数据)/resolveIsView(576)/getViewText(594)/getShowCreateTableDdl(617)/fetchSyntheticWriteColumns(727,读——getFullSchema 路径)/getNameToPartitionItems(820)/getNameToPartitionValues(881)/getMetadataTableRows(918)/getComment(950)/getSupportedSysTables(981)/(第 12 处 1304) | 159,189,576,594,617,727,820,881,918,950,981,1304 | on-thread/planning | +| PluginDrivenExternalDatabase.getLocation | 1 | 84 | on-thread | +| PluginDrivenScanNode(扫描 9 处,**存字段而非裸改**) | create(静态工厂,206) + convertPredicate(806)/tryPushDownLimit(846)/tryPushDownProjection(865)/pinMvccSnapshot(910)/pinRewriteFileScope(1055)/pinTopnLazyMaterialize(1074)/buildColumnHandles(1902)/buildRemainingFilter(1939) | 见左 | planning-thread | +| PluginDrivenMvccExternalTable(3 处) | materializeLatest(143,mixed)/loadSnapshot(358,on-thread)/resolveFreshnessProbe(725,background) | 143,358,725 | 见左 | +| misc(4 处命令/TVF) | ShowPartitionsCommand(285)/ConnectorExecuteAction(128)/CallExecuteStmtFunc(101)/JdbcQueryTableValueFunction(52) | 见左 | on-thread | + +**扫描节点存字段细节**:加懒字段 `private volatile ConnectorMetadata cachedMetadata;` + `metadata()` 访问器(`if(cachedMetadata==null) cachedMetadata = PluginDrivenMetadata.get(connectorSession, connector);`),8 处 per-method 全改调 `metadata()`。**create(206) 是静态工厂无实例** → 直接调 `PluginDrivenMetadata.get(session, connector)`(不能用实例访问器)。字段现状:`connector:148`、`connectorSession:149`、`currentHandle:152`;镜像现有 `volatile resolvedScanProvider:184`。8 处均在单规划线程(并发 appendBatch 之前)。 + +### B. 后台读穿加载器(跨语句缓存 loader,**强制 NONE 会话**,绝不绑定语句作用域) + +| 文件 | 缝 | 当前行 | 线程 | +|---|---|---|---| +| PluginDrivenExternalCatalog.listDatabaseNames | 1 | 295 | background | +| PluginDrivenExternalCatalog.listTableNamesFromRemote | 1 | 311 | background | +| PluginDrivenExternalTable.initSchema | 1 | 460 | background(schema-cache) | +| PluginDrivenExternalTable.getColumnStatistic | 1 | 1052 | background(stats-cache) | +| PluginDrivenExternalTable.getChunkSizes | 1 | 1087 | analyze-thread | +| PluginDrivenExternalTable.fetchRowCount | 1 | 1153 | **mixed**(含 ANALYZE 执行线程,见 §3) | +| MetadataGenerator.dealPluginDrivenCatalog | 1 | 1329 | background(BE-driven) | + +### C. 写入路径(**留后续步骤,C3 不碰**,共 8 处) + +`resolveWriteTargetHandle(PluginDrivenExternalTable:133)` + PhysicalPlanTranslator(612,660) + PluginDrivenInsertExecutor(206) + BindSink(673,712) + IcebergRowLevelDmlTransform(112) + PhysicalIcebergMergeSink(303)。 + +--- + +## 2. 强制 NONE 的机制(已源码证实为「与裸直调字节等价」) + +- `ConnectorSessionBuilder.withStatementScope(ConnectorStatementScope.NONE)` 显式短路 `captureStatementScope()` 的第一道 `!= null` 守卫 → 与线程 ConnectContext 状态**完全无关地强制 NONE**(`ConnectorSessionBuilder.java:190-203`)。 +- `ConnectorStatementScope.NONE.computeIfAbsent(key,loader) = loader.get()`(每次跑工厂、零缓存);`getOrCreateMetadata` 默认走它;`closeAll` 默认 no-op → NONE 无可关之物。 +- 故 `PluginDrivenMetadata.get(NONE会话, connector)` = **每次新建、零留存**,SPI 文档原话「byte-identical to building metadata every time」。 +- 7 处 loader 现状全是 `.buildConnectorSession()`(读 ConnectContext,有 ctx 即绑活作用域=泄漏隐患)。**修法 = 新增 `PluginDrivenExternalCatalog.buildCrossStatementSession()`**:镜像 `buildConnectorSession()` 两分支,末尾统一 `.withStatementScope(ConnectorStatementScope.NONE)`。7 处改调它。 + +--- + +## 3. ANALYZE 隐患(已证实,修法并入 fetchRowCount 强制 NONE) + +- `AnalyzeTableCommand.doRun` → `AnalysisManager.buildAnalysisJobInfo:349` → `:418` 在 `getRowCount()<=0` 时**同步直调** `table.fetchRowCount()`,全程在 ANALYZE 语句执行线程(ConnectContext + StatementContext 均活)。 +- `fetchRowCount:1152` 经 `buildConnectorSession()` 捕获该活作用域。 +- **今日无害**:fetchRowCount 现走**裸直调** `connector.getMetadata(session)`(不经漏斗),故什么都没 memo 进作用域,会话只是持了个引用。隐患是**前瞻性**的:一旦 fetchRowCount 改走漏斗(且 metadata 挂真实可关资源),会把资源钉在整个 ANALYZE 生命周期、并在语句末误关。 +- **修法**:fetchRowCount 用 `buildCrossStatementSession()` 强制 NONE。**无需单独改 AnalysisManager**——强制 NONE 后无论走不走漏斗都读穿。 + +--- + +## 用户拍板(2026-07-19) + +- **偏差①**:名字映射两缝 `fromRemoteDatabaseName(1106)`/`fromRemoteTableName(1112)` → **归入强制 NONE 读穿**(→ 读穿 9 处、改道 49 处)。 +- **偏差②**:读穿 loader → **走统一入口 + 传 NONE 会话**(甲案,门禁零例外)。 + +## 4. 两处需拍板的偏差(对抗复核揪出) + +### 偏差①:名字映射两缝 `fromRemoteDatabaseName(1106)`/`fromRemoteTableName(1112)` +- 设计原判:on-request 的 DDL 改道(进 A 类改道)。 +- 复核实证(两 agent 独立):其**全部调用方跑在离请求线程的名字缓存 loader / buildDbForInit / buildTableForInit / metastore-event-sync 路径**(`ExternalCatalog:586,970`;`ExternalDatabase:224,277,659`),与后台 loader 同族。裸改道进漏斗 → 若某次同步首载落在有活 ConnectContext 的请求线程,会把 memo 值绑进**活得比语句久的名字缓存**。 +- **建议**:与其它 7 处 loader 同等对待——**强制 NONE**(→ 读穿 9 处、改道 49 处)。 + +### 偏差②:读穿 loader 的落地形态(漏斗+NONE vs 裸直调+门禁白名单) +- 旧设计 §8 选「保留裸直调 + 防漂移门禁白名单」(当时理由「更清晰、避免塞残留 scope」,该理由在强制 NONE 后已失效——NONE 无 scope 可塞)。 +- 已证实「漏斗+NONE」与「裸直调」**字节等价**,且让防漂移门禁**零例外**(fe-core 里不再有合法的裸 `connector.getMetadata(`)。 +- **建议**:读穿 loader 也走漏斗(传 NONE 会话),门禁更干净。 + +--- + +## 5. 落地子提交建议(C3 内部,每步独立可编译/回退) + +1. 新增 `buildCrossStatementSession()` 助手(连接器无关,强制 NONE)。 +2. 后台读穿:7(或 9)处 loader 改用助手 + 走漏斗;含 fetchRowCount ANALYZE 隐患修复。守门:加载计数=1(对照 NONE=N)、跨 catalog/queryId 隔离、ANALYZE 首次不绑活作用域。 +3. 读/DDL/misc/MVCC 改道:49(或 51)处裸改道进漏斗。 +4. 扫描节点存字段:加 `cachedMetadata`+`metadata()`,9 处改调。 +- 防漂移门禁(bash grep,仿 `check-connector-imports.sh`)择机随 STEP 1 收尾补(形态取决于偏差②)。HMS 兄弟扇出 = 下一大步,不在 C3。 + +## 6. 已核实为「不动」 + +- 写入 8 处(§1.C);`getMetadataTableRows()` 数据调用(PluginDrivenExternalTable:923,非工厂缝);`TestExternalCatalog` 9 处测试静态 Map;`RuntimeProfile.node.getMetadata():260`;`PluginDrivenMetadata` 漏斗自身 4 处。 diff --git a/plan-doc/per-statement-table-owner-port/designs/P1-implementation-design.md b/plan-doc/per-statement-table-owner-port/designs/P1-implementation-design.md new file mode 100644 index 00000000000000..8cc915afe6b99e --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/P1-implementation-design.md @@ -0,0 +1,180 @@ +# P1 实现设计(seam-by-seam)—— 引擎自持每语句 ConnectorMetadata 实例 + +> **范围 = 仅 P1 键石**:让一条语句、一个 catalog(一个属主连接器)在读/扫描/DDL/MVCC 路径上**恰好复用一个 memoized `ConnectorMetadata` 实例**,`connector.getMetadata` 保持纯工厂、唯一 memo 在 fe-core 管道,语句末**确定性 close**。写路径(7 缝)与工作集下沉、按身份分片缓存分别留 P3/P2/P4。 +> **本文仍不动代码**:是"可动工蓝图 + commit 切分"。目标架构全景见 [`trino-parity-metadata-redesign-design.md`](./trino-parity-metadata-redesign-design.md)。 +> 全部 `file:method:line` 来自本轮只读 grounding(workflow `wf_7e537094-44f`,已核实)。 + +--- + +## 0. P1 成功判据 + +- 一条查询里,对同一 `(catalogId, 属主连接器)`,`connector.getMetadata(session)` 只被真正调用**一次**;读/扫描/DDL/MVCC 的所有 seam 复用该实例。 +- 该实例在**查询结束、BE/pump 静默之后**被确定性 `close()`(P1 里 close 仍是 no-op,只验证接线正确);预编译 EXECUTE 重执行不泄漏;异构 HMS 网关下每 sibling 一实例。 +- **P1 字节中性**:`NONE`(离线/测试/无 ConnectContext)下逐次工厂=今日行为;perf delta≈0(语句内去重本就有,收益=单漏斗 + 无 per-connector memo + 确定性生命周期)。 +- 守门:见 §7。 + +--- + +## 1. 新增 / 改动的 SPI 与 fe-core plumbing(精确签名) + +### 1.1 `ConnectorStatementScope`(fe-connector-api,加一个默认方法) +现状:` T computeIfAbsent(String key, Supplier loader)` + `NONE`(`connector/api/ConnectorStatementScope.java:45,48`)。 +**加**(默认方法,`NONE` 天然逐次不缓存): +``` +// 便利入口:类型化 metadata memo;默认经 computeIfAbsent,故 NONE 下逐次跑 factory +default ConnectorMetadata getOrCreateMetadata(String key, Supplier factory) { + return computeIfAbsent(key, factory); +} +// 语句末确定性关闭;默认 no-op,故 NONE 恒惰性 +default void closeAll() { } +``` +> 值仍以 `Object` 存(fe-core 不认连接器类型,守 connector-agnostic 铁律)。 + +### 1.2 `ConnectorStatementScopeImpl`(fe-core) +现状:`ConcurrentHashMap` + `computeIfAbsent`(`connector/ConnectorStatementScopeImpl.java:38-44`)。 +**加** `closeAll()`:遍历 values,对 `Closeable`/`AutoCloseable` 者 best-effort 关闭(log-and-continue,仿 `QueryFinishCallbackRegistry` 隔离);**幂等**(关一次后清标记/清表,重复调用无害——因 close 会从多处触发,见 §4)。 + +### 1.3 静态 funnel(fe-core,新类 `PluginDrivenMetadata`) +``` +public static ConnectorMetadata get(ConnectorSession session, Connector connector) { + ConnectorStatementScope scope = session.getStatementScope(); // 默认 NONE + String key = "metadata:" + session.getCatalogId(); // 普通连接器 + return scope.getOrCreateMetadata(key, () -> connector.getMetadata(session)); +} +``` +- `NONE`(无 ConnectContext/StatementContext)→ `getOrCreateMetadata` 走 `computeIfAbsent` 的 NONE 实现 → **逐次 `connector.getMetadata(session)`,零跨语句缓存**(off-thread loader 即便误路由也安全)。 +- `session` 仍要照常构造(后续 `metadata.xxx(session, …)` 要它);**只 memoize `getMetadata` 的结果**。 +- 证据:`session.getCatalogId()` 恒有(`buildConnectorSession` 一律 `withCatalogId(getId())`,`PluginDrivenExternalCatalog.java:1118`);`ConnectorSession.getCatalogId():69`、`getQueryId():31`。 + +### 1.4 `ConnectorMetadata.close()`(已存在,无需新增) +`ConnectorMetadata extends 6 Ops + Closeable`,默认 `close() throws IOException` 为 no-op;**无连接器 override、无 fe-core 调用点**(`connector/api/ConnectorMetadata.java:248`)→ 开始 memoize + 在 closeAll 里调它是**生命周期安全**的。P1 不改各连接器 close(仍 no-op),只验证接线。 + +### 1.5 close 回调注册(见 §4;不新开 `StatementContext.close` 步) + +--- + +## 2. 66 处 seam 的改道规则 + +| 组 | 位置 | 数量 | P1 处置 | 说明 | +|---|---|---|---|---| +| **A 读** | `PluginDrivenExternalTable`(13)+`ExternalDatabase.getLocation`(1) | 14 | **改道 funnel** | 均 on-thread;connector=`getConnector()`,session=`buildConnectorSession()` | +| **A/排除** | `PluginDrivenExternalTable.getChunkSizes:1087` | 1 | **保持直调(fallback)** | 统计分析线程,常无 ConnectContext | +| **B DDL** | `PluginDrivenExternalCatalog` DDL(19)+`tableExist`(1)+name-mapping(2) | 22 | **改道 funnel** | on-thread;connector=字段 | +| **B/排除** | `listDatabaseNames:295`、`listTableNamesFromRemote:311` | 2 | **保持直调** | name-cache loader,off-ConnectContext | +| **C 扫描** | `PluginDrivenScanNode`(create:205 + 8 处 per-method) | 9 | **改道 + 存字段** | 见 §3 | +| **D 写** | PhysicalPlanTranslator×2 / InsertExecutor / BindSink×2 / IcebergMergeSink / IcebergRowLevelDmlTransform | 7 | **P1 不动,留 P3** | 见 §6(read-vs-write 复用) | +| **E MVCC** | `PluginDrivenMvccExternalTable`(143/358/725) | 3 | **改道 funnel**(带 null-ctx fallback) | 主 on-thread,少数后台 metadata-table 扫描 | +| **F 排除** | `initSchema:460`、`getColumnStatistic:1052`、`fetchRowCount:1153` | 3 | **保持直调** | 跨语句缓存 loader,off-ConnectContext | +| **misc** | ShowPartitions/ConnectorExecuteAction/CallExecuteStmt/JdbcQueryTVF | 4 | **改道 funnel** | on-thread 命令/TVF | +| **misc/排除** | `MetadataGenerator.dealPluginDrivenCatalog:1329` | 1 | **保持直调** | BE 驱动 fetch,可能无 ConnectContext | + +- **改道机械动作**:`ConnectorMetadata m = connector.getMetadata(session);` → `ConnectorMetadata m = PluginDrivenMetadata.get(session, connector);`(connector/session 每处都在作用域)。 +- **排除项本质安全**:funnel 在 `NONE`/null-ctx 下本就 fallback 直调;但排除项**保留显式直调**更清晰、且避免误把跨语句 loader 的结果塞进某个残留 scope。 +- **假阳性**:`datasource/test/TestExternalCatalog.java` 的 8 处 `catalogProvider.getMetadata()` 是测试用静态 Map,非 SPI,勿动。 +- **negative**:`planner/PluginDrivenTableSink.java` 无 `getMetadata` 调用(sink 从 translator 收 session+handle),D 组工作全在 PhysicalPlanTranslator。 + +--- + +## 3. 扫描节点(组 C):存字段去 per-method 重取 + +`PluginDrivenScanNode` 已把 `connector`(:147)、`connectorSession`(:148)、`currentHandle`(:151) 存字段(构造期赋值 190-192,create 建 203-205)。 +- **加**懒字段:`private ConnectorMetadata cachedMetadata;` + `private ConnectorMetadata metadata(){ if(cachedMetadata==null) cachedMetadata = PluginDrivenMetadata.get(connectorSession, connector); return cachedMetadata; }`。 +- 8 处 per-method 重取(`convertPredicate:805`/`tryPushDownLimit:845`/`tryPushDownProjection:864`/`pinMvccSnapshot:909`/`pinRewriteFileScope:1054`/`pinTopnLazyMaterialize:1073`/`buildColumnHandles:1889`/`buildRemainingFilter:1926`)一律改调 `metadata()`。 +- **懒**(而非 create 传入):`JdbcQueryTableValueFunction.getScanNode` 直构造节点、手里无 metadata。 +- 8 处都跑在**单规划线程**(在并发 `appendBatch` 之前),但镜像现有 `volatile resolvedScanProvider` 模式加 `volatile` 更稳(off-path 若调 `metadata()` 防竞态)。 + +--- + +## 4. 确定性 close 接线(P1 最关键、含红队盲区①②的确切修法) + +> **⚠ 已更正(2026-07-19,workflow `wf_9250330b-e81` 取证 + 已实现于 commit `12f3e95239b`)**:本节原方案"注册点 = scope 首次创建时(`getOrCreateConnectorStatementScope`)"**会泄漏**——`captureStatementScope` 在**每次 on-thread 会话构建**就急切建 scope,而 DDL/`SHOW`/`DESCRIBE`/`EXPLAIN`/前台 `ANALYZE` 走 `Command.run` 建了 scope 却**永不到 `unregisterQuery`**,回调(连带 scope)永留无 TTL 的注册表。已改为**两层关闭**: +> - **主关闭**:注册点 = `PluginDrivenScanNode.getSplits`(挨着现有 read-txn 回调、同 queryId 键、对象捕获 `scope::closeAll`、跳过 `NONE`)。getSplits 只对**走协调器**的扫描/写/游标取数/内部查询触发,全部到 `unregisterQuery` → 无注册表泄漏、pump 静默后触发。 +> - **兜底关闭**:`StatementContext.close()` 加 `isReturnResultFromLocal` 守卫的 closeAll,覆盖不走协调器的 `Command` 语句(直连经 `ConnectProcessor.executeQuery` finally 已调 close;转发主节点在 `proxyExecute` 补 finally 调 close)。arrow-flight(异步返回)经守卫延后到其自身 query-finish close,不被提前关。 +> - **重置**:`resetConnectorStatementScope()` 改"先 closeAll 再置空";`handleQueryWithRetry` 每次重试前重置 → 预编译 EXECUTE / 重试复用的 StatementContext 不会 memoize 进已关闭 scope。 +> 幂等(closeAll close-once)保证主+兜底双触发时恰好关一次。下面 §4.1/§4.2 保留原始分析(含为何不用 `StatementContext.close` 作**唯一**钩子的红队盲区①),但**注册点以本更正为准**。残留风险见 expanded-scope 文档。 + +### 4.1 钩子 = 现有 query-finish 回调,**不是** `StatementContext.close()` +- 注册:`QeProcessorImpl.registerQueryFinishCallback(String queryId, Runnable)`(`QeProcessorImpl.java:216`)。 +- 触发:`unregisterQuery` → `QueryFinishCallbackRegistry.runAndClear(DebugUtil.printId(queryId))`(`:212`),在 `StmtExecutor.finalizeQuery`(`:1034`,`updateProfile(true)` 之后)里调;而 `coordBase.close()`(`handleQueryStmt:1582`)已先驱动 `Coordinator.close:817`→`FileQueryScanNode.stop:695`→`splitAssignment.stop()` **栅栏住 off-thread pump**。⇒ 回调**严格在 BE drain + pump 栅栏 + profile 等待之后**触发,每 queryId 一次。 +- key 对齐:`connectorSession.getQueryId()` = `DebugUtil.printId(ctx.queryId())`(`ConnectorSessionBuilder.from:75`),与 `runAndClear` 同 key。 +- 先例:hive 读事务释放 `buildReadTransactionReleaseCallback`(`PluginDrivenScanNode.java:687`,在 `getSplits:1207` 无条件注册)——`scope.closeAll()` **完全镜像它**(含 `onPluginClassLoader` TCCL pin,若 closeAll 触及连接器加载对象)。 +- 为何 `StatementContext.close()` 错(盲区①):它只 `releasePlannerResources()`(`StatementContext.java:979`,故意不碰 scope,注释 :205);它从 `ConnectProcessor.executeQuery:410` 在 execute() 之后跑、无对 BE/pump 的栅栏;SQL-cache 路径 `parseFromSqlCache:469` 只 `releasePlannerResources` **不 close**;**EXECUTE 复用一个 StatementContext 跨多次执行、只在最后 close** → 挂 close() 会漏掉每次中间执行的 scope。 + +### 4.2 注册点 = **scope 首次创建时**(修红队未点透的覆盖漏洞) +- grounding 指出:读事务回调注册**只在 `getSplits`**,而 `getSplits` **只有扫描语句到达**;写/纯元数据/DDL/EXPLAIN 语句在 `captureStatementScope`(`:202`)创建了 scope 却从不扫描 → 若照抄"getSplits 注册",这些语句的 `closeAll` **永不注册**。 +- **修**:在 `StatementContext.getOrCreateConnectorStatementScope()`(`:656`,scope 懒建的唯一入口)里,首次建 scope 时**向 `QeProcessorImpl` 注册一次** `queryId → scope.closeAll()`。⇒ 任何触连接器的语句都恰好注册一个 closeAll。 +- **幂等**:多张表/多 session 共享一个 scope、重试(`handleQueryWithRetry` 换 queryId 重跑 `getSplits`)都可能多次触发 → `closeAll()` 必须 **close-once**。 + +### 4.3 EXECUTE 复用(盲区③) +- `ExecuteCommand.run`(`:95`)每次执行调 `resetConnectorStatementScope()`(`StatementContext.java:669`,当前**只置空不 close**)。**修:改成先 `scope.closeAll()` 再置空**——作为兜底(某次执行的 scope 若在 analysis 阶段建、但计划短路没走到注册/触发)。因主 query-finish 回调可能已关同一实例,再次印证 closeAll 必须幂等。 + +### 4.4 取消/超时(盲区④) +- 反 pump 由 `Coordinator.close()`(取消/超时走 `:1396`)→`scanNode.stop()`→`splitAssignment.stop()` 栅栏;`SplitSourceManager` 另有 GC-based WeakReference reaper 兜底(`:72`)。close 回调走 query-finish、在 coord.close 之后,故**已在 pump 栅栏之后**,无需额外机制;仅需保证异常路径也走到 `unregisterQuery`(现有 finally 已覆盖)。 + +--- + +## 5. HMS 异构网关 sibling 扇出(组内特例,gateway 现已 LIVE) + +> **事实纠偏**:hms 已在 `SPI_READY_TYPES`(commit `83585fd5097`,2026-07-17,`CatalogFactory.java:56`),网关**服务真实查询**;代码里 ~20 处 "dormant until hms enters SPI_READY_TYPES" 注释是**过期**的。 + +- 网关行为:`HiveConnectorMetadata.getTableHandle:344` 按格式探测,ICEBERG/HUDI 表**转发 sibling**、返回 sibling 原生 handle;之后每个方法按 `instanceof HiveTableHandle` 判别,非 hive handle 经 `siblingMetadata(session,handle)=siblingOwnerResolver.apply(handle).getMetadata(session)`(`:292`)转发,**~43 处 per-handle 点每次新建 sibling metadata**。 +- owner 解析:`HiveConnector.resolveSiblingOwner(handle)`(`:190`)按 `ownsHandle` 三路派发**已建的** volatile sibling 字段(iceberg 先于 hudi),孤儿 handle fail-loud。每网关**恰好一个** iceberg + 一个 hudi sibling(`getOrCreateIcebergSibling:488` DCL)→ sibling Connector **对象身份稳定**。 +- **坑**:三连接器**共享一个 `catalogId`**(`createSiblingConnector` 传 `this` context,`DefaultConnectorContext.java:174`)→ **catalogId 单独做 key 会把三连接器塌成一个 metadata、派错**。 +- **修**: + 1. **funnel key 加属主 label**:`"metadata:" + catalogId + ":" + owner`,`owner ∈ {hive,iceberg,hudi}`,**在 `resolveSiblingOwner` 返回后按命中臂取 label**(非预解析、非 identityHashCode)。 + 2. **sibling getMetadata 也进 funnel**:`HiveConnectorMetadata.siblingMetadata`/`icebergSiblingMetadata`/`hudiSiblingMetadata` 里,先解析属主 Connector(照旧、可 fail-loud),再 `scope.getOrCreateMetadata(key(catalogId, ownerLabel), () -> owner.getMetadata(session))` → getTableHandle 的 by-TYPE 转发与后续 per-handle 转发**共享一个** sibling metadata/语句。 + 3. **只存/返回 `ConnectorMetadata` 接口**,绝不 cast 具体类型(跨 loader CCE)。 + 4. 补异构网关 e2e(`hms-iceberg-delegation-needs-e2e`,e2e 在 `external_table_p2/refactor_catalog_param`)。 +- 说明:scan/write/procedure provider 是 Connector 级(不在 getMetadata funnel),scan provider 已 per-handle memoize(PERF-11/C14);它们只共用 `resolveSiblingOwner` 的身份解析。 +- **注意**:sibling funnel 调用在**连接器内部**(fe-connector-hive),fe-core 的 arch 门禁看不见 → 门禁只管 fe-core 两文件(§8);sibling 一实例由连接器侧单测守(§7)。 + +--- + +## 6. read-vs-write 复用决策:P1 只做读侧,写侧留 P3 + +- 风险:部分连接器期望**每事务 metadata**(trino-connector 已用 `getMetadata(session, txn)` 2 参重载,`TrinoConnectorDorisMetadata.java:104`);把一个 metadata 实例跨读(扫描)+写(sink/insert)共享,可能违反事务语义。 +- **P1 决策:组 D(7 处写 seam)不改道,留 P3**。理由: + - P1 里 metadata 仍是**无状态外壳**(工作集 P2 才下沉),故"读侧共享一个实例"对写侧无影响;写侧维持现状(`PluginDrivenInsertExecutor` 已把 `writeOps` 按写语句自缓存,`ensureConnectorSetup:206`)。 + - 把"读+写同一实例/事务"的语义留给 P3 与事务折叠**一起**设计(那时才把 `ConnectorTransaction` 并入实例),避免 P1 就踩事务语义。 +- ⇒ **P1 的"一语句一实例"覆盖读/扫描/DDL/MVCC;写臂在 P3 汇入。** 这是干净的增量。 + +--- + +## 7. 守门与测试(全有现成模板) + +- **加载计数守门(连接器侧,iceberg)**:仿 `IcebergStatementScopeTest`(`:51-103`)——loader 内 `AtomicInteger`,共享=1、`NONE`=2;远端 loadTable 计数用 `RecordingIcebergCatalogOps.log`。连接器侧**须 copy `TestStatementScope`**(不能 import fe-core,bash 门禁会挂)。 +- **跨 catalog / 跨 queryId 隔离**:同文件两个 `ScopeSession` 仅 catalogId / queryId 不同,断言非同实例(已有 `differentCatalogIdIsolatesTheLoad`/`differentQueryIdIsolatesTheLoad`)。 +- **fe-core 侧 funnel + 预编译无泄漏**:仿 `ConnectorStatementScopeTest`(`:35-94`),真 `StatementContext` + `ConnectorStatementScopeImpl`;`resetConnectorStatementScope` 后断言换实例且旧值不存;**加断言 closeAll 被调**(用可数 close 的假值)。 +- **HMS sibling 一实例 + 扇出不重载**:仿 `HiveConnectorSiblingTest`(`:67-107`,`buildCount==1`/close-once/fail-loud-not-memoized);驱动 getTableHandle→scan/write 转发,断言 sibling 只 loadTable 一次(`RecordingIcebergCatalogOps.log` size)。 +- **close-once / 游标取数 / 转发 / 重试**:P1 净新。可数 close 的 scope 值,经批式 pump(`PluginDrivenScanNodeBatchModeTest` 风格)驱动,断言早终止/异常路径 close 恰一次。 +- **NONE 离线**:`ConnectorSessionImpl` ctor 默认 NONE、`captureStatementScope` 无 ctx 返回 NONE → 离线测试自动落 NONE。 + +--- + +## 8. arch 门禁(enforce invariant 2,非仅约定) + +- 机制 = **bash grep 门禁**(仿 `tools/check-connector-imports.sh`,同风格 + 自测),**非 ArchUnit**(classpath 无,勿引)。 +- 规则:grep fe-core 的 `connector.getMetadata(` 调用形,**只允许** `PluginDrivenExternalCatalog.java` 与 `PluginDrivenExternalTable.java`(fe-core 里唯二合法 funnel 载体;funnel 自身 `PluginDrivenMetadata` 内部那一处 factory lambda 也在允许名单)。 +- 排除项(§2 的 7 处直调 loader)要么走 funnel 的 null-ctx fallback、要么在门禁里显式白名单(建议白名单,保留显式直调语义)。 +- 落点:`fe/fe-connector/pom.xml` 现有 exec 只扫 fe-connector 根;fe-core 域的新规则需**自己的 exec execution**,或用 checkstyle `Regexp`(checkstyle.xml 已用 Regexp 模块)限定 `org.apache.doris.datasource.plugin`。 +- 正则须锚定调用形,别误伤 API 定义处与 `getMetadataTableRows`。 + +--- + +## 9. 未决 / 需你确认点 + +1. **写侧留 P3**(§6):P1 不改 7 处写 seam,读侧先"一语句一实例"。认可否?(替代:P1 也把写 seam 改道,但要先答 read-vs-write 事务语义——我不建议在 P1 冒这个险。) +2. **注册点 = scope 创建时**(§4.2):在 `getOrCreateConnectorStatementScope` 里向 query-finish 注册 closeAll(覆盖写/DDL/EXPLAIN)。认可否?(替代:只在 getSplits 注册=会漏非扫描语句。) +3. **排除项处置**(§2/§8):7 处 off-ctx loader **保留显式直调 + 门禁白名单**,而非依赖 funnel 的 null fallback。认可否? +4. **P4 残留泄漏威胁模型**(总设计 §8.3⑤):属独立安全签字,不阻塞 P1。确认 P1 期间**不动** `SchemaCacheValue`/`latestSnapshotCache` 等缓存门禁。 + +--- + +## 10. P1 落地顺序(建议 commit 切分) + +1. **C1 地基**:`ConnectorStatementScope.getOrCreateMetadata/closeAll` 默认方法 + `ConnectorStatementScopeImpl.closeAll`(幂等)+ `PluginDrivenMetadata` funnel。含 fe-core 单测(memo/NONE/隔离)。 +2. **C2 close 接线**:`getOrCreateConnectorStatementScope` 注册 query-finish closeAll + `resetConnectorStatementScope` closeAll-before-null。含 close-once / EXECUTE / 取消 用例。 +3. **C3 读侧改道**:组 A/B/E/misc(排除 §2 标注项)+ 扫描节点组 C 存字段。含加载计数守门。 +4. **C4 HMS sibling**:key 加属主 label + sibling getMetadata 进 funnel。含 sibling 一实例守门 + 异构网关 e2e。 +5. **C5 arch 门禁**:bash/checkstyle 规则 + 自测。 +- 每个 commit 独立可编译、可回退;C1 后即"每语句一实例"(读侧),C4 后覆盖异构网关。**纯连接器无关地基 + fe-core 改道**,用户已豁免铁律 A。 diff --git a/plan-doc/per-statement-table-owner-port/designs/P3-write-sharing-implementation-design.md b/plan-doc/per-statement-table-owner-port/designs/P3-write-sharing-implementation-design.md new file mode 100644 index 00000000000000..06ea7037deeeda --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/P3-write-sharing-implementation-design.md @@ -0,0 +1,56 @@ +# P3 实现设计 + as-built —— 写入共用一个每语句元数据实例 + 事务归属上移 + +> 本文记录“写入共用”这一步(读写共享同一个 memoized `ConnectorMetadata`,并把写事务上移成语句级共持体)的定稿设计与落地结果。设计先行、经用户确认“完整共持体”高度后实现。全部 `file:method` 来自本轮读码核实 + grounding workflow(`wf_1a053ce4-b22`,6 读者,其中写缝/闸门1/兄弟路由/纠缠点四读者产出,事务生命周期与身份闸门自读核实)。 + +## 0. 成功判据 +- 一条写语句(INSERT/DELETE/MERGE)里,读/扫描/写对同一 `(catalogId, 属主连接器)` 复用**同一个** memoized `ConnectorMetadata`;写事务从该实例上铸出。 +- 写入侧 8 处 `getMetadata` 裸调全部走统一收口 `PluginDrivenMetadata.get`,删除 8 个 `getMetadata-funnel-exempt` 标记 → fe-core 元数据收口门禁 100% 无例外。 +- 语句末对**未提交**(中途被杀的孤儿)事务确定性回滚,且**先于**关闭元数据;幂等,绝不回滚已提交事务。 +- 保留:hive 起写自取表刷新(闸门1)、读写身份一致(闸门2,fail-loud 断言)、tx↔session 绑定、全局事务注册(BE RPC)、执行器在 onComplete/onFail 的提交/回滚时机。 +- NONE(离线)下字节等价。 + +## 1. 第一步 —— 改道 + 身份闸门(commit `f208036f3c5`) +### 1.1 8 处写缝改道(均无状态只读外壳 + 1 处开事务) +`connector.getMetadata(session)` → `PluginDrivenMetadata.get(session, connector)`,删豁免标记: +- `PhysicalPlanTranslator.visitPhysicalConnectorTableSink`(INSERT)/ `buildPluginRowLevelDmlSink`(DELETE/MERGE) +- `PluginDrivenInsertExecutor.ensureConnectorSetup`(唯一“外壳 + 开事务”缝,返回 metadata 存 `writeOps` 字段) +- `PluginDrivenExternalTable.resolveWriteTargetHandle`(藏读文件里的写专用点) +- `BindSink.checkConnectorStaticPartitions` / `checkConnectorWritePartitionNames` +- `IcebergRowLevelDmlTransform.checkPluginMode` +- `PhysicalIcebergMergeSink.buildInsertPartitionFieldsFromConnector` +8 处各建自己的 `buildConnectorSession()`,但捕获同一每语句作用域 → 改道后命中读臂建好的那一个实例。 + +### 1.2 身份一致断言(闸门2) +`PluginDrivenMetadata.get` 里以 `session.getUser()`(Doris 主体、非令牌)记下“首建者身份”,复用时比对,不一致抛 `IllegalStateException`。一语句一用户恒成立→永不触发;将来若被破坏则 fail-loud 而非悄悄错用凭证。NONE 下存空、检查恒真。 + +### 1.3 闸门1(hive 起写)天然满足 +改道只动 `getMetadata` 工厂调用;hive 起写 `beginWrite` 的自取表在 `beginTransaction` 下游、未触。**纠正旧表述**:起写取表走 `CachingHmsClient` 缓存(与读同对象),吃重的是“写鉴权取 + 原始参数拒 ACID + 提交期唯一把关”,非“更新鲜快照”。闸门1 是“别把起写改成复用共享表”的前瞻约束。 + +## 2. 第二步 —— 事务归属上移(commit `a03b88b0d80`) +### 2.1 新增 `CatalogStatementTransaction`(fe-core,`datasource/plugin`) +co-hold 共享 `writeOps`(元数据写facet)+ session + 懒建的 `ConnectorTransaction`: +- `begin(writeHandle)`:`connectorTx = writeOps.beginTransaction(session, handle)` 从共享实例铸事务 + `mgr.begin(connectorTx)` 全局注册(BE RPC),返回事务给执行器绑 sink 会话。 +- `finalizeAtStatementEnd()`:仅当 `mgr.isActive(txnId)`(孤儿)才 `mgr.rollback(txnId)`;否则空操作。 +- 连接器无关:只持 `ConnectorWriteOps/Session/Transaction` 接口,绝不 cast 具体类型。 + +### 2.2 执行器改道(`PluginDrivenInsertExecutor.beginTransaction`) +经 `scope.computeIfAbsent("txn:"+catalogId, () -> new CatalogStatementTransaction(writeOps, session, mgr))` 取共持体,`connectorTx = stmtTxn.begin(writeHandle)`,`txnId = connectorTx.getTransactionId()`。NONE 下共持体瞬态、执行器自身提交/回滚为唯一生命周期,字节等价。 + +### 2.3 两趟 closeAll(`ConnectorStatementScopeImpl`) +- 趟1:对所有 `CatalogStatementTransaction` 调 `finalizeAtStatementEnd`(回滚孤儿)。 +- 趟2:关闭其余 `AutoCloseable`(memoized 元数据等)。 +→ “先了结事务、再关元数据”顺序显式钉死;今天元数据 close 空操作,未来变实事时该顺序必需。 + +### 2.4 幂等/安全论证 +`PluginDrivenTransactionManager.commit/rollback` 均 `transactions.remove(id)` → 管理器的 map 即“已了结”状态源;新增 `isActive(id)=containsKey`。正常/失败路径下执行器在语句末前已提交/回滚→map 已移除→closeAll 兜底空操作,**绝不回滚已提交事务**。只有中途被杀的孤儿会被兜底扫掉。 + +## 3. iceberg 表 side-car:正交、保持现状 +`IcebergStatementScope.sharedTable`(连接器内部每语句表缓存)与本步正交:改道只动 fe-core 的 `getMetadata`,事务上移只动 fe-core 事务生命周期,均不碰起写读表路径。其删除属更远期连接器内部重构(把表变实例字段),本步不做。 + +## 4. 测试与守门 +- 收口身份断言:同用户复用、异用户 fail-loud(2 新)。 +- 共持体:begin 注册;finalize 回滚孤儿/绝不撤已提交/回滚后幂等/无事务空操作(`CatalogStatementTransactionTest`)。 +- 两趟顺序:txn 先于 metadata 关(`ConnectorStatementScopeTest`)。 +- 三写路径套件补 NONE 作用域桩(改道后经收口解引用作用域)。 +- 门禁:fe-core 收口门禁 exit 0(写入零裸调)+ 自测 PASS;连接器 import 门禁 exit 0;checkstyle 0。 +- 异构网关写/开事务上一步已免费覆盖(兄弟经同一 memoized 收口),e2e 沿用“择机统一补”。 diff --git a/plan-doc/per-statement-table-owner-port/designs/P4-cache-isolation-implementation-design.md b/plan-doc/per-statement-table-owner-port/designs/P4-cache-isolation-implementation-design.md new file mode 100644 index 00000000000000..20f0e222d7f201 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/P4-cache-isolation-implementation-design.md @@ -0,0 +1,57 @@ +# P4 实现设计 —— 缓存隔离(授权敏感缓存的越权修复,独立安全 track) + +> 本文记录"缓存隔离"这一步的定稿设计。设计先行、经用户确认路线后实现。全部 `file:method:line` 来自本轮读码核实 + grounding workflow(`wf_ffad1480-f41`,6 reader recon + 3 安全断言对抗验证)。**本文不引任务代号,用户已豁免铁律 A(fe-core 可增源)。** + +## 0. 问题 —— list ≠ load 的真实越权 + +某些 iceberg 目录(REST + `iceberg.rest.session=user`)按登录用户逐个鉴权:每个用户拿自己的委派令牌去远端 REST 服务器"加载表",服务器决定该用户能否看这张表。**"能列出表名"与"能加载表"是两套独立授权**(Polaris/Unity 支持 per-table 授权)。 + +鉴权发生在**加载表的远端往返里**(`IcebergCatalogOps.loadTable:340-341` 走 per-user 委派 SDK 目录),FE 侧无独立鉴权层。**任何"命中缓存直接返回、不调 loadTable"的路径都跳过了鉴权** → 无权用户读到别人才该见的元数据(越权,非纵深防御)。用户已确认 list ≠ load。 + +## 1. Grounding 对原计划的三处纠正(对抗验证产出) + +1. **真正活跃的泄漏只有两个,不是四个。** + - `latestSnapshotCache`(**真漏**):`beginQuerySnapshot`(`IcebergConnectorMetadata.java:1610-1622`)读它时 `loadTable` 在**未命中加载器里面**——命中就返回、不加载表。按表名建键、无用户维度、无条件构建、session=user 下仍注入 per-user metadata(`IcebergConnector.java:202-203/255-257`)。默认 24h TTL、每条查询走(`SUPPORTS_MVCC_SNAPSHOT` 无条件),活跃。泄漏值 = `(snapshotId, schemaId)` 两个 long。 + - `partitionCache` / `formatCache`(**非活跃漏**):每个读取点都**先调 per-user `resolveTableForRead`/`resolveTable`(现载、因 session=user 下 tableCache=null)**,无权用户在那步被拦。今天安全,但依赖"上游恰好先加载表",**脆弱**。 + - 先例:`commentCache`(`IcebergConnector.java:223-232`)早已对 session=user 排除,注释明说"共享 comment 缓存绕过 per-user loadTable 鉴权 = 元数据披露"——同一类威胁,当年用排除解决。 +2. **fe-core 表结构缓存是第二个真漏。** `SchemaCacheKey` 只按 `nameMapping`(`SchemaCacheKey.java`),**无 schema 缓存 bypass**。开发者给库/表名缓存加了 bypass 挡越权(`shouldBypassDbNameCache/TableNameCache`),却漏了 schema 缓存。无权用户命中 → 拿到别人的列结构。 +3. **异构 HMS 网关是"潜在"洞,不是"最尖的活跃洞"。** 网关内嵌 iceberg 兄弟被**无条件强制 `iceberg.catalog.type=hms`**(`IcebergSiblingProperties.synthesize:62-63`),而 session=user 要求 REST flavor → **兄弟永不可能 session=user**,泄漏今天不可达(被 hms-forcing 不变量挡住,非被 fe-core 门挡住)。原计划"传属主标签到 fe-core 分片"被证伪:属主标签只是类型判别串(`"iceberg"`/`"hudi"`),非身份/能力,且活在每语句作用域、不桥接 fe-core 跨语句缓存。存在的是**结构性隐患**(fe-core bypass 只看前门 hive 能力、看不到被委派兄弟能力),作前瞻加固处理。 + +## 2. 用户决策(2026-07-20 签字) + +- **路线 = 禁用**(非身份分片):授权敏感的跨查询缓存在 `isUserSessionEnabled()` 时置空。→ 无需新 SPI `getIdentityShardKey()`;**无撤权陈旧窗口**(每次现载即时鉴权);无后台线程身份漂移风险;与现有 `tableCache`/`commentCache` 先例完全一致。session=user 本就是"每次现载原始表"档位,投影现算成本可忽略。 +- **范围 = 三个投影缓存统一处理**:`latestSnapshotCache`(真漏) + `partitionCache` + `formatCache` 一并禁用 → 铁律"session=user ⇒ iceberg 无活跃跨查询元数据缓存"成立,移除脆弱依赖。 +- **异构网关 = 加 fail-loud 守卫**:网关建 iceberg 兄弟时断言兄弟非 session=user;今天恒不触发(兄弟强制 hms),守未来。 +- **威胁模型签字**:影响面仅 REST + `iceberg.rest.session=user` + fe-core schema 缓存;泄漏为元数据(snapshotId/schemaId + schema 列)非凭证/数据;禁用路线下无撤权陈旧窗口。 + +## 3. Seam-by-seam 实现(四小步,各独立 commit) + +### 4a · iceberg 三投影缓存禁用(`fe-connector-iceberg`) +- **构造门控**(`IcebergConnector.java:202-222`):三个缓存均改 `isUserSessionEnabled() ? null : new Iceberg*Cache(...)`(镜像 tableCache 的 null 门;tableCache 门为 `isUserSessionEnabled() || restVended`,投影缓存与凭证过期轴无关故只需前者)。 +- **失效路径补 null 守卫**(`IcebergConnector.java` `invalidate/invalidateDb/invalidateAll`,行 567/571/572、591/595/596、611/615/616):三缓存的失效调用当前**无条件**,null 化后 ALTER/REFRESH/DROP 会 NPE → 加 `!= null` 守卫(镜像 tableCache/commentCache 已有守卫)。 +- **`beginQuerySnapshot` 补 null 回退**(`IcebergConnectorMetadata.java:1614`):`latestSnapshotCache != null ? getOrLoad(...) : `(镜像 `resolveTableForRead:614` 的 tableCache 三元)。 +- **读取点已 null-safe,无需改**:`partitionCache`(`IcebergPartitionUtils.loadRawPartitions:744-745` 有 `cache == null` 回退)、`formatCache`(`IcebergWriterHelper.resolveFileFormatName:319` 有 `cache == null` 回退)。 +- 测试:session=user 目录下三 `*CacheForTest()` 返回 null;`beginQuerySnapshot` 在 null 缓存下仍正确 pin(每次现载);非 session=user 目录字节不变(缓存仍在)。 + +### 4b · fe-core 表结构缓存 bypass(`fe-core`,镜像名字缓存 bypass) +- **基类默认**(`ExternalCatalog.java`,`shouldBypassDbNameCache:348` 附近):`protected boolean shouldBypassSchemaCache(SessionContext ctx) { return false; }`。 +- **插件 override**(`PluginDrivenExternalCatalog.java:1202` 附近):`return supportsUserSession() && ctx != null && ctx.hasDelegatedCredential();`(与 `shouldBypassTableNameCache` 同判据同措辞)。 +- **读取点拦截**(`ExternalTable.getSchemaCacheValue():435`,唯一 key 构造点):bypass 时 `initSchemaAndUpdateTime(key)` 现读、不碰共享缓存。覆盖 MVCC 路径(`PluginDrivenMvccExternalTable` 的 latest 分支经 `cachedSchemaCacheValue()→super.getSchemaCacheValue()` 汇入本方法;time-travel pin 已 per-user 解析)。 +- 测试:session=user + 有委派凭证 → bypass(现读,不入缓存);无凭证 → 保留缓存(fail-closed 在连接器侧);非插件目录默认 false 不变。 + +### 4c · 异构网关 fail-loud 守卫(`fe-connector-hive`) +- `HiveConnector.getOrCreateIcebergSibling():510` 建成兄弟后断言 `!sibling.getCapabilities().contains(SUPPORTS_USER_SESSION)`,违则抛(消息说明:前门 hive 非 session=user,fe-core per-user 缓存 bypass 不触发,session=user 兄弟会静默泄漏;兄弟应恒 hms flavor)。今天恒不触发。 +- 测试:正常兄弟(hms flavor)不抛;桩一个 session=user 兄弟 → 抛。 + +### 4d · 防漂移门禁(`tools/check-authz-cache-sharding.sh` + `.test.sh` + maven validate) +- 仿 `check-fecore-metadata-funnel.sh`:standalone bash grep gate,RED/GREEN mktemp 自测,exec-maven-plugin validate 挂 `fe-connector` pom(`inherited=false`)。 +- 目标:`IcebergConnector.java` 构造里每个 `this.\w*[Cc]ache =` 赋值语句(到 `;` 的整条)须**含 `isUserSessionEnabled(`** 或带豁免标记 `authz-cache-exempt: <理由>`(供 manifestCache 这类 default-off + 读在 per-user load 之后的缓存)。缺则构建失败——把"加了新缓存忘了对 session=user 处理"从静默泄漏变构建失败。 +- manifestCache(`IcebergConnector.java:181`,default-off `meta.cache.iceberg.manifest.enable` + 读在 loadTable 之后)用 `authz-cache-exempt` 标记 + 一句理由。 + +## 4. 后续 +- 越权 e2e(can-list-cannot-load 命中不泄漏 + 异构网关)落 `regression-test/suites/external_table_p2/refactor_catalog_param`,随读写共用步骤欠的异构网关 e2e 一并"择机统一补"。 + +## 5. 不做 +- 不新增 `getIdentityShardKey()` SPI(禁用路线不需要)。 +- 不动 `partitionCache`/`formatCache` 的读取点(已 null-safe)。 +- hive/paimon/hudi 无 SUPPORTS_USER_SESSION、无按用户授权轴,不动。 diff --git a/plan-doc/per-statement-table-owner-port/designs/expanded-scope-phasing-and-security-decisions.md b/plan-doc/per-statement-table-owner-port/designs/expanded-scope-phasing-and-security-decisions.md new file mode 100644 index 00000000000000..5d7069553ce6de --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/expanded-scope-phasing-and-security-decisions.md @@ -0,0 +1,96 @@ +# 扩展范围定稿:分期 + 后台读穿纠正 + 缓存隔离升级为安全修复 + +> 本文承接 `P1-implementation-design.md` §9 的四个待确认点。用户 2026-07-19(session 3)就"是否把写入共用 / 后台线程传递 / 缓存隔离一并纳入当前工作"做了拍板,并触发一轮取证 workflow(`wf_8b907b93-e9f`,18 agents,recon + 对抗验证 + 综合)。本文记录**用户决策、取证结论、定稿分期**,并更正 `P1-implementation-design.md` §9 点 3 与总设计里"后台传递"的隐含框架。 +> **本文仍不动代码**。 + +--- + +## 0. 用户决策(2026-07-19 session 3,动码前) + +`P1-implementation-design.md` §9 四点的实际裁决(部分偏离原推荐): + +| 点 | 原推荐 | 用户裁决 | 影响 | +|---|---|---|---| +| ① 写侧留后续 | 只做读侧 | **读写一起做**(但经取证 → 排在读取键石之后、独立成步) | P3 提前进入范围,但**分期**、不与键石混提交 | +| ② close 注册点 | scope 创建时 | **认可**(scope 创建时) | 不变 | +| ③ 后台加载点 | 显式直调 + 白名单 | **要求"把统一句柄传到后台线程"** → 取证判定该方向**不安全**,纠正为"显式读穿 + 修隐患" | 见 §2 | +| ④ 缓存隔离 | 本阶段不动、留后续签字 | **现在就处理** + 确认 **list ≠ load** | 缓存隔离升级为**真实安全修复**,独立安全 track,见 §3 | + +**综合裁决**:**分期推进**(非一次性全做)。三块都做,但排序 + 独立提交 + 独立验证。 + +--- + +## 1. 取证结论 A —— 读写共用一个元数据实例:可行且忠实 Trino + +- **Trino 已核实**:`CatalogTransaction` 每(事务, catalog)memoize **恰好一个** `ConnectorMetadata`,读规划与写入**共用它**;写入经共享事务句柄绑定,提交 = `connector.commit(txnHandle)`。我们的目标形状与之**完全一致**。 +- **机制**:把统一入口存的"裸 metadata"升级成 `CatalogStatementTransaction` 共持体(持 memoized metadata + 首次写时懒建的 `ConnectorTransaction`)。现成范本 = `ConnectorRewriteDriver`(co-hold metadata/session + `beginTransaction` + commit/rollback)。 +- **写臂 8 处 getMetadata 改道进同一入口**(均已核实为无状态薄壳的只读用法 + 一次起事务):`PhysicalPlanTranslator.visitPhysicalConnectorTableSink:660`(INSERT) / `buildPluginRowLevelDmlSink:612`(DELETE/MERGE)、`PluginDrivenInsertExecutor.ensureConnectorSetup:206`、`PluginDrivenExternalTable.resolveWriteTargetHandle:133`(藏在"读"文件里的写专用点)、`BindSink.checkConnectorStaticPartitions:673`/`checkConnectorWritePartitionNames:712`、`IcebergRowLevelDmlTransform.checkPluginMode:112`、`PhysicalIcebergMergeSink.buildInsertPartitionFieldsFromConnector:303`。 +- **两条必守的正确性闸门**(非性能): + 1. **按连接器保留"起写刷新"**,不一刀切。iceberg 复用语句内共享表(新鲜度靠 `newTransaction()` refresh);**hive 起写必须保留其在鉴权上下文里当场 `hmsClient.getTable`**(`HiveConnectorTransaction.beginWrite:199/208-211`)——负责 ACID 事务表拒绝 + 权威起始快照。误换成扫描缓存表 = 正确性/授权 bug。 + 2. **身份一致性闸门**:iceberg 接 REST、session=user 时 metadata 烤进 per-user 委派操作(`IcebergConnector.getMetadata:256` `newCatalogBackedOps`)。读/写会话在同一语句本是同一用户,但**须显式断言二者身份指纹相等**再共用,否则理论上"拿 A 的对象执行 B 的写"。 +- **不做的事**:不统一两个 `ConnectorSession`(保留 `setCurrentTransaction` 绑定,更大且无正确性必要);不引入事务协调器(外部写仍每语句 autocommit,1 StatementContext ≈ 1 外部事务)。 +- **HMS 兄弟**:sibling 入口必须覆盖 **beginTransaction** 路由(`HiveConnectorMetadata:1854`),不仅 getMetadata;键含属主 label,写事务从与读同一个 funnel-memoized 兄弟实例上 mint。 +- **风险**:iceberg session=user 身份错配(首要正确性闸门);hive 起写刷新丢失;兄弟 downcast;提交/收尾**不得**再调 getMetadata 建第二个实例;maxcompute/jdbc 写壳仅旁证,接入前须确认无状态 + 事务可从共享 metadata mint;scope-map 生命周期须在语句末确定性 close/rollback 未提交事务,且幂等。 + +## 2. 取证结论 B —— "后台线程传递"被证伪,纠正为"读穿 + 修隐患" + +**用户原要求"把统一元数据对象传到后台线程",取证判定不安全,应反向做。** + +- **为何不安全**:那几个后台加载点填的是**跨语句共享缓存**(活得比语句久);把每语句实例塞进去、语句末 close → 共享缓存里留**已关闭对象**。后台线程是**全进程复用固定池**(`ExternalMetaCacheMgr` scheduleExecutor),一个查询关掉的资源可能被**无关查询**在同一池线程上误用 → 崩溃/串数据。**明确拒绝 InheritableThreadLocal / 在 worker 上 set**。 +- **正确模型(两分支,也正是 Trino)**: + - **语句派生的异步**(扫描 pump、将来写侧异步)→ **提交时捕获**:复用请求线程上建好的 session(`PluginDrivenScanNode.connectorSession` final 字段,`ConnectorSessionImpl.statementScope` 是 final,脱线程可达)。规则:新异步一律把请求线程 session/scope 传进闭包,**绝不在池 worker 上 `buildConnectorSession()`**(那里 `ConnectContext.get()==null` → 落 NONE)。 + - **真正跨语句的后台缓存加载器**(7 处)→ **一律读穿**:每次临时新建、**显式声明 NONE**(`ConnectorSessionBuilder.withStatementScope(NONE)` 或新增 `buildCrossStatementSession()`),绝不绑定语句实例。7 处 = `PluginDrivenExternalCatalog.listDatabaseNames:295`/`listTableNamesFromRemote:311`、`PluginDrivenExternalTable.initSchema:460`/`getColumnStatistic:1052`/`getChunkSizes:1087`/`fetchRowCount:1153`、`MetadataGenerator.dealPluginDrivenCatalog:1329`。 +- **真实隐患(顺手修)**:`fetchRowCount` **并非**只跑在后台——`AnalysisManager.buildAnalysisJobInfo:415/417` 在 **ANALYZE 语句的执行线程**上直调 `table.fetchRowCount()`,该线程有活的 StatementContext → 会**错误绑定到 ANALYZE 语句的作用域**。今天无害(值是 `Optional`),但一旦实例挂可关闭资源即 use-after-close。→ **对 7 处一律强制 NONE**,把"读穿"从"碰巧落在哪个线程池"变成**强制契约**。 +- **Trino 对齐**:Trino 后台走跨事务缓存(`CachingHiveMetastore`)读穿,不复用事务 metadata;我们**匹配**,并**额外加**一道显式-NONE 守门(因我们的捕获是 thread-driven,Trino 不需要)。 + +## 3. 取证结论 C —— 缓存隔离:确认 list ≠ load,升级为真实安全修复 + +**用户确认 list ≠ load** → "能列举但无权加载"的用户会经缓存命中读到别人授权才见的表结构/快照/分区。**这是真实越权,非纵深防御。** + +- **影响面小**:只涉及 **iceberg 的投影缓存 + fe-core 表结构缓存**;hive/paimon/hudi 无 SUPPORTS_USER_SESSION、无按用户授权轴,**不动**。 +- **两轴切分(对齐 Trino 的切线)**:**只对"不含凭证、授权敏感"的投影按身份分片**(表结构/快照 id/分区规格);**含凭证的原始表 + FileIO 一律不跨语句缓存**(凭证会过期;身份轴 ≠ 令牌过期轴)。 +- **SPI 原语**:新增连接器无关的 `ConnectorSession.getIdentityShardKey()`(默认取 `getUser()`,源自 Doris 主体非令牌字节 → fe-core 不解析凭证,守铁律)。off-thread loader 也读它,防指纹在请求线程与异步线程间漂移。 +- **iceberg**:把 shard key 放进当前**未门控**的三个投影缓存的 Key——`latestSnapshotCache`/`partitionCache`/`formatCache`(`IcebergConnector` cache 块 ~200-232),仅 `isUserSessionEnabled()` 时填充(否则常量 → 非 session=user 目录字节不变)。关掉残留泄漏(总设计 §8.3⑤:`beginQuerySnapshot` 命中跳过 per-user loadTable)。 +- **fe-core 表结构缓存**(唯一引擎侧泄漏,`SchemaCacheKey` 仅按 nameMapping):**分层**——(a) 先加 `shouldBypassSchemaCache(SessionContext)`,session=user + 委派凭证下**绕缓存现读**(镜像已安全的库/表名缓存 bypass),最小正确修;(b) 后续若性能需要再把 identityShardKey 织入 Key(触及广泛调用的 `getSchema` 签名 + off-thread loader,更侵入)。 +- **原始表缓存保持 OFF**(`tableCache` 两条件 null 门维持);语句内复用仍靠 `IcebergStatementScope.sharedTable`(及 P2 后的实例字段)。**P4 恢复的是投影 RPC 的跨语句复用,不恢复 scan 期 loadTable**(那是 P2)。 +- **HMS 异构网关是最尖的洞**:hive 前门(无 SUPPORTS_USER_SESSION → fe-core 名字缓存 bypass 不触发)委派给 session=user 的 iceberg 兄弟时会泄漏;shard key 与 Key 选择须按**有效属主连接器身份**、且经兄弟 getMetadata 路由传播。**必须与 P1 的"键含属主连接器"一并落 + 异构网关 e2e。** +- **防漂移门禁**:加 arch/checkstyle 规则——授权敏感的跨语句缓存的 Key 在 session=user 下**必须**含 `getIdentityShardKey()`,把"加了新缓存忘了分片"从静默泄漏变成构建失败。 +- **Trino 对齐**:切线一致(只缓存无凭证投影、FileIO 每请求现挂);Trino 的身份分片是 opt-in(impersonation),默认靠缓存之上的 access-control 层,故 **Doris 的"session=user 恒分片"比 Trino 默认更严**(需签字确认这是有意选择)。放置差异:Trino 分在**工厂层**(per-user 缓存实例),我们放**Key 里**(贴合 Doris 扁平有界缓存 + 总设计措辞),同等隔离、靠防漂移门禁补工厂模型的免费防漂移。 +- **待签字/待答**:授权新鲜度(TTL 内远端撤权仍命中,Trino 同性质,可接受但须签字);manifestCache(默认 OFF,开启则须分片或声明与 session=user 不兼容)。 + +--- + +## 4. 定稿分期(不砍任何一块,只排序 + 独立提交/验证) + +> **为何不一次性全做**:①硬依赖——写入无入口可改道,直到读取键石落地(写严格下游);②"后台传递"被证伪,非独立第三块,是键石 close 接线的一条正确性约束;③缓存隔离是独立安全工程(未决威胁模型 + 缺 SPI 原语 + 与重构正交),捆进 ~64 处机械改道会给签字施压、且越权回归无法二分定位。约六十多处改道 + 写 + 授权缓存同落刚稳定的性能热路径 = **不可二分的回归面**。 + +| 步 | 内容 | 依赖 | 验证 | +|---|---|---|---| +| **STEP 1 · 读取键石**(原 P1,含后台纠正) | 统一入口 `PluginDrivenMetadata.get` + `getOrCreateMetadata`/`closeAll`(幂等)+ 读/扫描/DDL/MVCC 改道 + 扫描节点存字段 + close 走现有 query-finish、注册在 scope 创建点 + `resetConnectorStatementScope` 先 closeAll 再置空。**7 处后台加载器显式强制 NONE 读穿 + 修 `fetchRowCount` ANALYZE 隐患**。 | — | 加载计数=1(NONE=N);跨 catalog/queryId 隔离;游标/转发/重试/SQL-cache close 恰一次 | +| **STEP 2 · HMS 兄弟扇出**(P1-design §5) | 键 =(catalogId, ownerLabel);兄弟 getMetadata **及 beginTransaction** 经同一入口;异构网关 e2e | STEP 1 | 每 sibling 一实例、加载计数=1 | +| **STEP 3 · 写入共用**(P3,拆两小步) | **3a** 无状态写点改道进入口(translator×2 / executor / resolveWriteTargetHandle / BindSink×2 / IcebergMergeSink / RowLevelDml);**3b** `ConnectorTransaction` 归属上移到 `CatalogStatementTransaction`,定 commit/rollback vs closeAll 顺序 | STEP 1+2 | 3a 门:读/写会话身份等价(iceberg session=user);保留 hive 起写刷新;保留 tx↔session 绑定 | +| **STEP 4 · 缓存隔离**(P4,独立安全 track,可与 1–3 并行启动) | `getIdentityShardKey()` SPI → iceberg 三投影缓存 Key 分片 + fe-core 表结构缓存 bypass(先)/分片(后) + 防漂移门禁;随 STEP 2 的属主键一并覆盖异构网关 | 威胁模型签字 + STEP 2(属主键) | 越权 e2e:can-list-cannot-load 用户命中不泄漏;异构网关 e2e | + +**纠缠点(STEP 3 时再定,现在不决)**:iceberg"起写复用已解析表"目前读 `IcebergStatementScope.sharedTable`,而该 side-car 在 P2 计划里要删。到 STEP 3b 请用户定:先接旧 side-car、P2 再搬 vs 先做个 iceberg 最小 P2 前置。 + +--- + +## 5. 对既有文档的更正 + +- `P1-implementation-design.md` §9 点 3:从"显式直调 + 白名单"**更正为"显式强制 NONE 读穿 + 修 fetchRowCount ANALYZE 隐患"**。§2 排除项组(A/排除、B/排除、F 排除、misc/排除)处置随之从"保持直调"细化为"显式 NONE session"。 +- 总设计"后台传递/off-thread 构造期捕获"表述保留正确(捕获机制没错),但**"把实例传给后台线程"这一用户方向被证伪**——后台跨语句 loader 必须读穿,仅语句派生异步捕获。 +- `P1-implementation-design.md` §9 点 4 与总设计 §8.3⑤:缓存隔离**不再是"本阶段不动 + 远期签字"**,而是**已确认 list≠load 的真实安全修复**,作为 STEP 4 独立 track,威胁模型签字仍需,但不再推迟到"某个远期"。 + +--- + +## 6. STEP 1 实现进展(2026-07-19 session 3 续) + +- **C1 地基**(commit `5b7312f9d1f`):`ConnectorStatementScope.getOrCreateMetadata/closeAll` 默认方法 + `ConnectorStatementScopeImpl.closeAll`(幂等 close-once) + 静态漏斗 `PluginDrivenMetadata.get`。fe-core 单测:memo-once / NONE-each-call / 跨目录隔离 / closeAll 关一次。**字节中性**(无生产路径接进漏斗)。 +- **C2 关闭接线**(commit `12f3e95239b`):**两层关闭**(见 `P1-implementation-design.md` §4 更正块)。主关闭=`PluginDrivenScanNode.getSplits` 注册 `scope::closeAll`(对象捕获、跳 NONE、同 read-txn queryId 键);兜底=`StatementContext.close()` 的 `isReturnResultFromLocal` 守卫 closeAll(直连 `executeQuery` finally + `proxyExecute` 新增 finally);`resetConnectorStatementScope` 改 closeAll-before-null;`handleQueryWithRetry` 每重试重置。单测:reset-先关 / close-本地关 / close-异步延后。**P1 关闭仍 no-op,本 commit 行为中性**。 +- 取证 workflow:`wf_9250330b-e81`(scope 创建普查 / unregisterQuery 覆盖 / 边界路径,各带对抗复核)。 + +### 关闭接线的残留风险(carry-forward,多为既有/共担) +1. **取消/超时非硬栅栏**:`SplitAssignment.stop()` 只置 `isStopped` 标志、不 join `scheduleExecutor` 上的批读 future;慢的在途 `planScanForPartitionBatch` 可能在 closeAll 清表后再 `computeIfAbsent` 塞值(不崩,但那值不被关)。**既有、与 read-txn 回调共担**,非本改动引入。P1 no-op 关闭下无实害;**关闭做实事前须硬化**(join pump 或 closed-guard computeIfAbsent)。 +2. **arrow-flight 异常断连**:`FlightSqlConnectProcessor.close` 不跑 → 注册表条目留存(无 TTL)。既有、共担。 +3. **待确认**:走协调器的 arrow-flight/内部查询若碰外部目录却**从不走 getSplits**(纯 information_schema / 某些元数据 TVF)→ 无主关闭 + arrow-flight 跳兜底 → 可能泄漏。需在 STEP 3/后续确认这类路径必走 getSplits 或本地兜底。 +4. **🔴 TCCL 自钉扎(硬前置,给"关闭做实事"的那一步)**:本 commit 关闭是 no-op 故无需;一旦某连接器把 `ConnectorMetadata.close()`(或 P2 的 FileIO/Table)做成实事,**主关闭(getSplits 注册的回调)与兜底(StatementContext.close/proxyExecute)两处都必须把 TCCL 钉到 provider 的插件 classloader**(同 read-txn 释放回调 + `TcclPinningConnectorContext` 的 locus 纪律),否则跨类加载器 split-brain。**连接器 `close()` 自钉扎是首选**(fe-core 保持 connector-agnostic、不持 classloader)。 diff --git a/plan-doc/per-statement-table-owner-port/designs/recon-findings-and-trino-refactor-groundwork.md b/plan-doc/per-statement-table-owner-port/designs/recon-findings-and-trino-refactor-groundwork.md new file mode 100644 index 00000000000000..e7a6327fd1dc5f --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/recon-findings-and-trino-refactor-groundwork.md @@ -0,0 +1,169 @@ +# 每语句表加载归属者 · 跨连接器复核结论 + Trino 重构议题预备 + +> **本文件定位**:把"把 iceberg 的每语句表加载归属者移植到其它连接器"这件事的**全部调研结论**固化到一处,供后续 session 直接接手。 +> **本轮不动任何生产代码**。用户拍板:先把结论记成文档;**重构成 Trino 架构的问题留到下一个 session 专题讨论**(见 §7)。 +> 蓝本/地基/铁律见同目录 [`README.md`](../README.md);进度见 [`progress.md`](../progress.md) / 状态见 [`tasklist.md`](../tasklist.md)。 + +--- + +## 0. 一句话结论 + +- **逐连接器复核(recon + 独立对抗复核,双签):没有一个连接器现在值得移植。** iceberg 之所以值得,唯一原因是它是本仓库里**唯一迁移了行级写(DELETE/MERGE)**的连接器——行级写让同一张表在一条语句里被"读+扫描+写成形+开事务"反复远端加载 3~5 次,这才是那套改造的靶子。别的连接器要么在 Doris 侧只读、要么仅 INSERT 追加写,没有这个重复加载风暴;现有的跨查询缓存又已把加载压到 ≈1 次。 +- **"统一接口标准"其实已经存在**:中性 SPI `ConnectorSession.getStatementScope()` + `ConnectorStatementScope`(带 `NONE` 默认)。每个连接器天生继承,离线自动退化成安全的逐次加载。新连接器**不是没有标准可循**。 +- **真正的将来触发点 = paimon 加行级写**。paimon 现在已经是"iceberg 改造前"的形状(有胖句柄 + 多加载 seam),只差写臂;一旦加 UPDATE/DELETE 就会复现风暴,届时移植才有价值,且那是抽取共享 helper 的正确时机(有 iceberg + paimon 两个真实用户)。 +- **推荐高度 = L0**(写下约定 + 登记触发点,生产逻辑零改动);**L1**(抽共享 helper)留到 paimon 加写时;**L2/L3(Trino 式重构)** 是远期、跨切、与删旧代码期铁律 A 冲突的独立项目——**下个 session 专门讨论**。 + +--- + +## 1. 背景(给未来读者的最小上下文) + +新 SPI 框架下,一条 DML(尤其 DELETE/MERGE)里同一张表被反复远端 `loadTable`:读元数据、扫描规划、写成形、开事务各自加载,最糟一条语句 3~5 次。连接器 session 一条语句内被重建约 26 次,缓存挂它即死。唯一贯穿整条语句的对象是 fe-core 的 `StatementContext`。 + +iceberg 已通过 **PERF-07** 修掉(commit `97bdcd6bdbe` 建地基 + `ea7fd1f6e7a` 连接器侧): +- **中性地基(已就位、勿再改)**:`ConnectorStatementScope`(fe-connector-api) + `ConnectorSession.getStatementScope()` + `ConnectorStatementScopeImpl`/`StatementContext` 懒建/重置 + `ConnectorSessionImpl` 构造期捕获 + `ExecuteCommand` 重置(fe-core)。 +- **连接器侧范式**:`IcebergStatementScope` helper(`sharedTable` 键 `iceberg.table:catalogId:db:tbl:queryId` + `rewritableDeleteSupply`)+ 四处表解析共享 + 拆胖句柄(删 `IcebergTableHandle.resolvedTable`)+ 下沉跨臂删除暂存(删 `IcebergRewritableDeleteStash` 单例)+ v3 DML under NONE fail-loud。 + +**移植一个连接器 = 纯连接器侧工作,不再碰 fe-core**(不触铁律 A)。 + +--- + +## 2. 逐连接器复核结论(recon + 对抗复核,双签,2026-07-19) + +> 方法:每个候选一个深度 recon agent(数一条 DML 真实加载几次 / 现有缓存边界 / 胖句柄 / 跨臂暂存 / 鉴权凭证语义),再配一个**独立对抗复核 agent**(专挑"加载被现有缓存兜住""共享泄漏"两类刺)。以下均为双签、置信=高。 + +| 连接器 | 有写入面? | 一条 DML 真实远端加载 | 胖句柄 | 跨臂暂存 | 凭证泄漏 | 结论 | +|---|---|---|---|---|---|---| +| **paimon** | ❌ 未迁移(只读) | DML=0;SELECT≈1 | ✅ 有 `PaimonTableHandle.paimonTable`(载荷型,非纯 memo) | ❌ | 无(目录级单一身份;vend 仅用于下游存储) | **将来候选**(触发点=加行级写) | +| **hive / hms 网关** | ⚠️ 仅 INSERT/OVERWRITE | DML=N/A;写≈0~1 | ❌(纯坐标+扁平标量) | ❌ | 无(连接器级单一身份) | **不必做** | +| **hudi** | ❌ 只读 | DML=0;读侧 ~4-6 次未缓存 metaClient 构建 | ❌ | ❌ | 无 | **不必做** | +| **maxcompute** | ⚠️ 仅 INSERT 追加 | 低(句柄带惰性表代理,写复用不重载) | ❌ | ❌ | 无 | **排除** | +| **es** | ❌ 只读 | 读侧 mapping 有重取(已被 fe-core schema 缓存兜) | ❌ | ❌ | 无 | **排除** | +| **jdbc** | ⚠️ 仅 INSERT 追加 | 低(纯坐标句柄,写复用坐标) | ❌ | ❌ | 无 | **排除** | +| **trino** | ❌ 只读 | 读侧 `getTableHandle` 每 seam 重解析、**最贵且未缓存** | 同句柄内 memo | ❌ | 无 | **排除**(读侧优化候选,另立议题) | + +### 关键证据(file:method,便于将来核验) + +**hive / hms 网关(不必做,置信高)** +- 无行级写:`HiveWritePlanProvider.supportedOperations()` = `EnumSet.of(INSERT, OVERWRITE)`;full-ACID 写在 `HiveConnectorTransaction.rejectTransactionalWrite`(beginWrite:210) 硬拒。DML 风暴结构性不存在。 +- 加载已被**跨查询**缓存兜住:读/写/beginWrite 三处 `getTable` 全走 `CachingHmsClient.tableCache`(跨查询、TTL 24h、按 (db,table) 键)→ 实际远端 ≈0~1 次,是"每语句作用域"的**超集**。已核实缓存**确实接线**(`HiveConnector.createClient:588` = `wrapWithCache(new ThriftHmsClient(...))`;`hms` 在 `CatalogFactory.SPI_READY_TYPES`)——代码里多处 "dormant/未进 SPI_READY_TYPES" 的 javadoc 是 cutover 后的**过期注释**(doc-hygiene 待清)。 +- 无胖句柄(`HiveTableHandle`=纯坐标+扁平标量);无跨臂暂存(`HiveReadTransactionManager` 是 ACID 读锁生命周期管理器,非扫描→写桥)。 +- **网关委派已查清**:网关只对普通 hive 表自己加载;遇 iceberg/hudi-on-HMS 表只做 **1 次廉价格式探测加载**(`getTableHandle:343`)后委派兄弟连接器,兄弟自带作用域——**网关自己不需要任何作用域**,也不得重复包裹委派加载。 + +**paimon(将来候选,置信高)** +- **写未迁移**:`PaimonConnector` 不 override `getWritePlanProvider`(→ null → `supportedWriteOperations()` 空 → INSERT/DELETE/MERGE 全拒);代码注释 `PaimonConnector.java:294` 明写 "paimon write is not migrated"。 +- 加载已被句柄 memo + SDK 缓存压到 ≈1:`getTableHandle:185` 单次 `catalogOps.getTable` 后 `setPaimonTable` 存到 transient 句柄;后续 ~8 处 `resolveTable`/`resolveScanTable` 走快路径。跨查询由 paimon SDK `CachingCatalog` 去重。 +- **胖句柄存在**:`PaimonTableHandle.paimonTable`(transient, PaimonTableHandle.java:89) 是 `IcebergTableHandle.resolvedTable` 的直接同款。但**是载荷型非纯 memo**:`withBranch` 故意置 null 让分支重载不同表、`withScanOptions` 拷贝它、承担查询内 Table 实例一致性——现在删它换作用域 map 是**平移(等价功能、无减载)+ 有回归面**。 +- 无跨臂暂存(无写臂)。鉴权=目录级单一身份;vended 凭证只用于下游 per-scan-range 存储读、不烘进 Table 对象,故共享**不泄漏**。 + +**hudi(不必做,置信高)** +- 只读:不 override 写入面,`beginTransaction`(HudiConnectorMetadata.java:397) 抛 "Hudi tables are read-only";INSERT/DELETE/MERGE 在准入门被拒。 +- 5 步模板 4 步空操作。唯一真实重复成本=读路径 ~4-6 次**未缓存的 `HoodieTableMetaClient` 构建**(每 seam 从 basePath 重建)——但**形状不对**(不是"表加载归属者")、**metaClient 可变**(`getSchemaFromMetaClient` 调 `reloadActiveTimeline()`)、**鉴权/TCCL 上下文错配**(metadata 臂在 `metaClientExecutor.execute` 的 doAs 里、planScan 在 fe-core 扫描线程内联无 doAs),且主成本(FileSystemView / MDT `getAllPartitionPaths`)压根不是表对象加载。 + +**maxcompute·es·jdbc·trino(排除,对抗复核确认非橡皮图章)** +- 均无行级 DML:es/trino 只读、jdbc/maxcompute 仅 INSERT-append 且写成形复用句柄/坐标不二次加载。无跨臂暂存、无 v3 复活风险、凭证目录级。 +- 诚实指出:**读路径确有"每 seam 重解析"未缓存**——trino 最重(`getTableHandle`+`getColumnHandles`+N×`getColumnMetadata` 每 seam 重跑)、es 次之(mapping 重取)。但这正是蓝本明确打折的"读侧、收益有限、已被 `SchemaCacheValue` 兜一部分"那类,且缺写侧/暂存/胖句柄的架构收益。**trino 标记为最弱排除**——若将来单独立"读侧去重"新任务,它是这组里唯一有实打实重解析成本的。 + +### 一个不改结论的开放问题 +- paimon/hive 的句柄是否跨 fe-core 边界被重建(使加载数从 1 涨到 2-3)尚未 trace 确认。但即便最坏情况,那些多出来的加载**也全部命中现有跨查询缓存**,不改变任何"不必做"的结论。 + +--- + +## 3. 核心洞察:iceberg 为什么独特 + +那套改造 5 步(① sharedTable ② 四处 seam ③ 拆胖句柄 ④ 下沉暂存 ⑤ fail-loud)的价值,几乎全押在 **③④⑤ 依赖"行级写"** 上: + +- **只有 iceberg 迁移了行级写(DELETE/MERGE)**,它同时读 + 写同一张表,才有"写成形×N + beginWrite"这些绕过读缓存的加载臂,以及"扫描填、写读"的跨臂删除暂存。 +- 其它连接器:paimon/hudi/es/trino **只读**、hive/hms **仅 INSERT/OVERWRITE**、jdbc/maxcompute **仅 INSERT-append**。对它们 ①② 要么冗余(现有缓存已兜)、③④⑤ 基本是空操作。 + +因此"逐连接器复核判不必做"不是保守,而是**iceberg 本就是特例**。 + +--- + +## 4. 架构统一性分析(回答用户"是否有必要改造和统一") + +### 4.1 统一接口其实已存在 +Doris 版的统一契约 = 中性 SPI `ConnectorSession.getStatementScope()` + `ConnectorStatementScope`(`computeIfAbsent(key, loader)` + `NONE` 默认)。**每个连接器天生继承,离线安全退化。** `IcebergStatementScope` 只是 40 行的连接器私有便利包装。所以"新连接器没有统一标准"这个担心其实不成立——标准已发布,缺的只是"更强的强制/便利外壳"。 + +### 4.2 Trino 参照模型:统一靠"引擎自持的每事务生命周期",不是一个缓存类 +- Trino 每条语句都是一个事务;引擎为每个被访问的 catalog **懒造一个"每事务 `ConnectorMetadata`"实例并 memoize 到事务上**(`IcebergTransactionManager`/`HiveTransactionManager` 内的 per-catalog `MemoizedMetadata`),commit/rollback 时销毁。 +- 连接器把加载到的表**缓存在这个实例上**(iceberg `IcebergMetadata.tableMetadataCache`、hive per-transaction metastore 的 `tableMetadataCache`)。`getTableHandle`/`getTableMetadata`/`getColumnHandles`/统计/`beginInsert`/`finishInsert` 都重入**同一个** metadata → 命中缓存 → 远端 `loadTable` 只发一次。 +- **统一的是"共享生命周期"(去重的 span=事务、活满 span 的对象=metadata 都由引擎供给和销毁),"cache-on-metadata" 是自然掉出来的约定**;新连接器实现 `ConnectorMetadata` 即免费获得。 + +### 4.3 为什么 Trino 模型不可直接移植到 Doris +Doris 缺 Trino 的每一个结构前提: +- **无每事务 `ConnectorMetadata`**:Doris 连接器/`*ConnectorMetadata` 是**长期共享单例**(每 catalog 一个、从不按语句造),挂每语句可变缓存会跨语句/跨用户泄漏(正因如此跨查询 `IcebergTableCache` 对 `session=user`/vended 关闭)。 +- `ConnectorSession` **不是稳定 span**(一条语句重建约 26 次,挂它即死)。 +- **DML 事务对象出现得晚、且只在写臂**(`*ConnectorTransaction` 在 beginWrite 才建),无法承载"读+扫描+写成形"整条 span。 +- **唯一活满整条语句、连接器够得到的对象 = fe-core `StatementContext`**,只能经中性 `getStatementScope()` 够到。所以 Doris 必须把 Trino 模型**反过来**:不是"引擎把每事务 metadata 发给连接器",而是"连接器向上够 fe-core 的 span"。 +- 全盘照搬会撞铁律 A:给 14 个连接器引入每事务 metadata + TransactionManager 生命周期 + 改 planner 每语句取 metadata = 删旧代码期的 fe-core 大净增。 + +**结论:现有 `ConnectorStatementScope` + `getStatementScope()` + `NONE` 已是 Trino 思想在 Doris 铁律下能落地的最高、最可移植高度。** + +### 4.4 高度分级(L0–L3) +| 高度 | 内容 | 现在值不值 | +|---|---|---| +| **L0** | 只把统一**约定写成文**(键格式 `<类型>.table:catalogId:db:tbl:queryId`、NONE=逐次加载、扫描→写跨臂在 NONE 下 fail-loud)+ 登记触发点;生产逻辑零改动 | ✅ **推荐(现在做)** | +| **L1** | 把 iceberg 私有 helper 提升为 `fe-connector-api` 共享 util、迁移 iceberg 6 处调用(纯连接器侧、fe-core 不动、不碰铁律 A) | ⚠️ 留到 paimon 加写(第 2 个真实用户)时 | +| **L2** | 每语句基类 `ConnectorMetadata` / 契约方法连接器 override | ❌ 需引入每语句 metadata=迷你 Trino 重构 or 只是更重的 L1;碰铁律 A、影响刚稳定的读热路 | +| **L3** | Trino 式每事务 metadata 全重构 | ❌ 远期、跨切、明确碰铁律 A、需独立立项 + 单独签字 | + +--- + +## 5. 将来触发点:paimon 加写(证据) + +- **paimon 的"写未迁移"是真实的、被推迟的路线项**:老的 JNI-writer INSERT 写栈(commit `6c6c9a4b6cd`:`datasource/paimon/PaimonTransaction`、`planner/PaimonTableSink`、`nereids/.../PaimonInsertExecutor`、`transaction/PaimonTransactionManager` + BE `paimon_table_sink_operator`/`PaimonJniWriter`)在**删旧代码期被整体删除、未搬进新 SPI 连接器**。将来的 paimon 写会在写 SPI 上**全新构建**,且 UPDATE/DELETE 是真正新增(老实现只有 INSERT)。 +- **paimon 现在就是"iceberg 改造前"的形状**:胖句柄 `PaimonTableHandle.paimonTable` + 多加载 seam(4 文件,所有连接器最多)。**唯一缺写臂。** +- **一旦加行级 UPDATE/DELETE**:长出"写成形 + beginWrite"两条加载臂 + (若 merge-on-read)"扫描填、写读"的删除暂存 → **完整复现 iceberg 3~5 次加载风暴 + 跨臂暂存需求**。届时移植对 paimon**真正有价值**(架构连贯 + 溶掉胖句柄 + 给暂存一个自然的家),且有 iceberg + paimon 两个真实用户来把共享 helper 的接口形状定对。 + +--- + +## 6. 共享 helper 落点(若将来做 L1) + +- **家 = `fe-connector-api`**(`ConnectorStatementScope`/`ConnectorSession` 已在此;所有连接器都依赖它;是依赖图最低公共祖先;**连接器侧、不碰铁律 A**)。`fe-connector-spi`/`fe-connector-metastore-api` 在其之上,只依赖 api 的连接器看不见,排除。 +- **签名**(一个真原语 + 一个键约定便利): + ```java + static T computeIfAbsent(ConnectorSession s, String key, Supplier loader) { + return s == null ? loader.get() : s.getStatementScope().computeIfAbsent(key, loader); + } + static T sharedTable(ConnectorSession s, String typePrefix, String db, String tbl, Supplier loader) { + if (s == null) return loader.get(); + String key = typePrefix + ":" + s.getCatalogId() + ":" + db + ":" + tbl + ":" + s.getQueryId(); + return s.getStatementScope().computeIfAbsent(key, loader); + } + ``` +- **iceberg 迁移代价 = 6 处调用 + 删 `IcebergStatementScope`(85 行)**:`sharedTable` 4 处(`IcebergConnectorMetadata:613` 读 / `IcebergConnectorTransaction:211` beginWrite / `IcebergWritePlanProvider:697` 写成形 / `IcebergScanPlanProvider:2274` 扫描);`rewritableDeleteSupply` 2 处(`IcebergScanPlanProvider:674` 填 / `IcebergWritePlanProvider:193` 读)。 +- **注意**:删除暂存那半只能**结构性**泛化(值类型 `Map>` 是 iceberg thrift、"NONE 不桥接→v3 fail-loud"语义是 iceberg 专属)——泛化时把 iceberg 专属键串 + fail-loud 守卫留在 iceberg 调用点即可。**只有 table-share 那半是真连接器中性。** + +--- + +## 7. 下一步:下个 session 讨论"重构成 Trino 架构"(L2/L3)—— 预备材料 + +> 用户 2026-07-19 指示:本轮先记文档;**重构成 Trino 架构的问题下个 session 专题讨论**。以下是给那次讨论的种子,避免从零起。 + +### 7.1 Trino 架构到底要引入什么(对照 §4.2/§4.3) +1. **每语句/每事务 metadata 实例**:把 `*ConnectorMetadata` 从"每 catalog 共享单例"改成"每语句(或每事务)新建、由引擎 memoize+销毁"。 +2. **一个 span 生命周期管理器**(对应 Trino `TransactionManager`):即便裸 SELECT 也要有一个活满整条语句的 metadata 宿主;Doris 现成的 span 宿主是 `StatementContext`。 +3. **planner 改造**:Nereids/规划各阶段改成"经 span 取当前语句的 metadata 实例",而非直接调共享连接器。 +4. **连接器改造**:把表/schema/统计缓存从"跨查询共享缓存 + transient 胖句柄"迁到"挂 per-statement metadata 实例"。 + +### 7.2 必须先摆平的硬冲突(讨论要点) +- **铁律 A(删旧代码期 fe-core 只减不增)**:上述 1/2/3 几乎全是 fe-core 净增 + planner 改造。→ 讨论:是否等删旧代码期结束再做?是否需要用户单独签字放行? +- **读热路径刚稳定**(PERF-01~06、PERF-11):per-statement metadata 会重排读热缝,回归面大。 +- **凭证/多租户语义**:per-statement 实例天然按语句隔离(=按用户),能顺带解决"跨查询缓存对 session=user/vended 关闭"的历史包袱——这是 L3 相对现状的**真正架构收益**,值得作为讨论的正面理由。 +- **收益 vs 成本的诚实定位**:现状"连接器向上够 span"已拿到 90% 的去重收益;L3 的增量主要是**架构连贯 + 多租户缓存归一 + 为将来大量写连接器铺路**,而非单点性能。要不要为这些付"跨切重构 + 独立项目"的价,是用户的战略决定。 + +### 7.3 建议的讨论产出 +- 一份"Doris 每语句/每事务 metadata 重构"的**可行性 + 分期**设计(可否分"先 span 宿主、后逐连接器迁移、最后退役共享单例"三期落地,避免一次性大爆炸)。 +- 明确触发条件(如:删旧代码期结束 / 写连接器数量到某阈值)。 + +--- + +## 8. 参考 + +- 蓝本权威设计:`plan-doc/perf-hotpath-iceberg/designs/FIX-PERF-07-unified-per-statement-table-owner-{design,summary}.md`。 +- 代码范本:`fe-connector-iceberg/.../IcebergStatementScope.java`(commit `ea7fd1f6e7a`);地基 commit `97bdcd6bdbe`。 +- 架构记忆:`iceberg-table-resolution-cache-scoping`(缓存作用域纪律 + Trino 协同 + "全高度留远期")。 +- 本轮调研原始返回(每 agent 完整结论):workflow journal + `.../subagents/workflows/wf_e89cf92e-ff3/journal.jsonl`(逐连接器 recon+对抗复核)、 + `.../subagents/workflows/wf_4802a3d2-1c9/journal.jsonl`(placement / onboarding / Trino-altitude 三专项)。 +- 公开 tracking issue:apache/doris#65185。 diff --git a/plan-doc/per-statement-table-owner-port/designs/trino-parity-metadata-redesign-design.md b/plan-doc/per-statement-table-owner-port/designs/trino-parity-metadata-redesign-design.md new file mode 100644 index 00000000000000..92e30add0200b4 --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/designs/trino-parity-metadata-redesign-design.md @@ -0,0 +1,228 @@ +# 目标架构设计:把外部表元数据层重构成"每语句/每事务 metadata 实例"(对齐并超越 Trino) + +> **本文定位**:这是"是否把 Doris 表解析/元数据层重构成 Trino 式架构"专题讨论的**设计草案**。 +> **前提(用户 2026-07-19 拍板)**:本设计**不受任何现有铁律/约束限制**——可改任何模块(fe-core / planner / 连接器)。唯一目标:设计一个**至少和 Trino 一样合理、能更好则更好**的架构,不被当前实现绑住。 +> **本轮仍不动任何生产代码**:先出设计、用户确认方向后再实现。 +> 现状事实全部经一轮只读并行 recon + 对抗验证取证(workflow `wf_72d1e505-75c`),下引 `file:method:line` 均来自该轮核实。 + +--- + +## 0. 结论先行 + +1. **可行,且比上一轮结论文档判断的容易得多。** 上一轮 §4.3 的关键前提"Doris 的 `*ConnectorMetadata` 是每 catalog 长期共享单例"**是错的**(见 §1 更正)。真相是:`ConnectorMetadata` 现在是**每次调用新建、即用即弃的无状态外壳**,生命周期比"每语句"还短。要把它变成"每语句一个",不是要拆一个顽固单例,而是**给一个本就无根的临时对象钉一个语句级的家**——这恰恰是 Trino 的做法。 +2. **Doris 现状已经具备 Trino 四条不变量里的两条半**:句柄不可变且携带事实(不变量③,已满足);语句级宿主 `StatementContext` 已存在且已托管 MVCC pin 与连接器作用域(不变量①的宿主已就位);缺的是"引擎在管道层 memoize 唯一 metadata 实例"(不变量②)和"生命周期即事务、按用户天然隔离"(不变量④)。 +3. **目标架构 = 引擎自持的"每语句(每事务)metadata 实例" + 其下一层"跨语句、按身份分片的共享缓存"**。前者给结构化的"一语句一次加载"(比 Trino 的 iceberg 更硬),后者给按用户的跨语句复用(补上今天 session=user 下缓存被全关的窟窿)。 +4. **诚实的收益定位**(必须带着走的老结论):"每语句实例"本身**不解决**按用户的**跨语句**性能——那要靠"按身份分片的缓存"(分期里的 Phase 4)。每语句实例真正的价值是**架构自洽 + 结构性正确**(把今天散落的 6 处"这个值是不是跨用户敏感"的门禁,收敛成一条结构性质:实例是每语句、天然单用户)。 + +--- + +## 1. 现状的真实形状(经核实,含一处对上一轮文档的更正) + +### 1.1 三层生命周期(这是理解一切的钥匙) + +| 对象 | 生命周期 | 持有什么 | 对应 Trino | +|---|---|---|---| +| **`Connector`** | **每 catalog 一个长期单例**(`transient volatile` 字段,仅 ALTER/重启/close 重建) | **全部状态**:各类跨查询缓存(iceberg 的 snapshot/table/partition/format/comment/manifest;paimon 的 snapshot+schemaAt;hive 的 fileListing;maxcompute 的 partition)、`ConnectorContext`、鉴权 | ≈ Trino 的 `Connector`(每 catalog 单例) | +| **`ConnectorMetadata`** | **每次 `getMetadata(session)` 调用新建、即弃** | **几乎无状态**,是把连接器单例的缓存注进来的**薄委派外壳** | ≈ Trino 的 `ConnectorMetadata`——但 Trino 是**每事务一个并被引擎 memoize**,Doris 是**每次调用一个、从不 memoize** | +| **`ConnectorSession`** | **每次 `buildConnectorSession()` 新建**(一条语句 ~26–63 次),且**贵**(每次 `VariableMgr.toMap()` 全量反射 dump + 全局读锁) | queryId、委派凭证、捕获的语句作用域引用 | ≈ Trino 的 `ConnectorSession`——但 Trino 的是**廉价不可变请求上下文**,Doris 的是**昂贵且被反复重建** | + +证据:`PluginDrivenExternalCatalog.java:102`(connector 字段)、`IcebergConnector.getMetadata:256`(`return new IcebergConnectorMetadata(...)` 每次新建,7 个连接器皆然,grep 无任何 `ConnectorMetadata` 类型字段)、`ConnectorSessionBuilder.java:158-179` + `VariableMgr.toMap:940-959`(每次 build 的反射开销)。 + +> **⚠ 对上一轮结论文档的更正**:`designs/recon-findings-and-trino-refactor-groundwork.md` §4.3 与 `HANDOFF.md` 写的"Doris 连接器/`*ConnectorMetadata` 是长期共享单例(每 catalog 一个、从不按语句造)"——**把 `Connector`(确是单例)和 `ConnectorMetadata`(其实是每调用即弃)混为一谈了**。对抗验证判定该表述 **REFUTED**(证据:`IcebergConnector.getMetadata:256` 等 7 处)。这个更正是**利好**:让 metadata 变成每语句,不需要打破单例,只需给一个"生命周期本就是自由变量"的临时对象钉个语句级的家。 + +### 1.2 规划器怎么拿元数据(每处都在重造) + +从 SQL 到执行,每个 seam 都在重复同一个三元组:**长期 catalog 单例 → 长期 Connector 单例 → 新建 `ConnectorSession` → `connector.getMetadata(session)`(每调用新 metadata)→ 从 metadata 拿一个不可变 `ConnectorTableHandle`**。**没有任何"每语句 metadata 对象"被引擎穿针引线地传下去。** + +需要改道的 seam(recon 已枚举,择要): +- **读**:`PluginDrivenExternalTable` 里 ~17 处(`initSchema`/`getColumnStatistic`/`fetchRowCount`/`getNameToPartitionItems`/`toThrift`/`getComment`/`listPartitions`…),每处都 `buildConnectorSession`+`getMetadata`。 +- **扫描**:`PluginDrivenScanNode` 在 `create()` 把 session/connector/handle 捕获成**字段**(单扫描节点内共享),但之后每个方法(`applyFilter`/`applySnapshot`/`planScan`/`getColumnHandles`…)仍 `connector.getMetadata(session)` 重取。 +- **写**:`PhysicalPlanTranslator.visitPhysicalConnectorTableSink`(自建 session)、`PluginDrivenTableSink.bindDataSink`(planWrite)、`PluginDrivenInsertExecutor`(**第二个** session + `writeOps` 字段 + `beginTransaction`)。写路径**两个独立 session**,仅靠把 `ConnectorTransaction` 绑到 sink 的 session 上勉强串起来。 +- **异步缓存 loader**(schema/rowCount/columnStats):**在没有 `ConnectContext` thread-local 的线程上跑**——任何"每语句对象"必须能不经 thread-local 够到(现有作用域用**构造期捕获**已解决此问题,可复用)。 + +### 1.3 已就位的"半个 Trino 形状"(利好) + +- **句柄已是 Trino 模型**:`ConnectorTableHandle` 不可变,由 `applyFilter/applyProjection/applyLimit/applySnapshot/applyRewriteFileScope/…` 返回**新句柄**逐步精化——这正是 Trino 的 `ConnectorTableHandle`-update 模式。**Trino 不变量③(事实随句柄前向流动、规划期不重解析)Doris 已满足。** +- **语句级宿主已存在**:`StatementContext` 每语句在 `StmtExecutor` 构造时新建、挂到 `ConnectContext`,已托管 MVCC 快照 pin(`StatementContext.snapshots`,一语句一次解析、读写共享)与连接器作用域,随语句 GC。`ConnectorStatementScope`(挂在它上的 `ConcurrentHashMap` memo 竞技场)+ iceberg 的 `IcebergStatementScope` 已用它做到"一语句一张表只加载一次"。 +- **但作用域只是"半成品"**:它是**无类型的 String→Object 竞技场**,不是**有类型的每语句 `ConnectorMetadata`**;**规划器不经它取表**(规划器走的是跨语句的 `ExternalCatalog` 元数据缓存);只有 iceberg 一个连接器手写键去消费它。也就是说"读规划的取表路径"和"每语句加载归属者"**今天还不是同一条缝**——而 Trino 把它们合成了一条。 + +### 1.4 事务模型(现状割裂) + +- **外部无统一事务管理器**:每个 `ExternalCatalog` 自持一个 `TransactionManager`;plugin 目录一律用 `PluginDrivenTransactionManager`,把 commit/rollback/close 委派给**连接器在 `beginTransaction` 才晚建**的 `ConnectorTransaction`(`session.allocateTransactionId()`),绑到 session、`onComplete` 提交/`onFail` 回滚。 +- **`ConnectorTransaction` 是糟糕的 span 宿主**:出生太晚(仅 beginWrite)、只在写臂、读/规划期根本看不见。 +- **内部 OLAP 是另一条路**:`GlobalTransactionMgr` + 每连接 `TransactionEntry`;`GlobalExternalTransactionInfoMgr` 只是个 id→Transaction 的注册表(供 BE→FE RPC 查),**不是协调器**。内外无共享抽象。 +- **外部写实际上是"每语句 autocommit"**:`ConnectorTransaction` 在一条语句内建、在该语句 `onComplete` 提交 → **一个 `StatementContext` ≈ 一个外部事务**。这让 `StatementContext` 成为 span 宿主是**自然且正确**的。 + +--- + +## 2. Trino 为什么优雅(四条不变量)+ 两处弱点 + +经源码核实(`trinodb/trino` master,下引类名/行号来自该轮 web recon): + +**四条不变量:** +1. **事务 = 元数据的身份与生命周期单位**。无显式 BEGIN 的语句跑在 autocommit 事务里;每个被触达的 catalog,`TransactionManager` 懒建并 memoize **一个** `CatalogMetadata`(`activeCatalogs` map,`CatalogHandle` 为键);其内 `CatalogTransaction` 用 `@GuardedBy(this)` 字段 memoize **恰好一个** `ConnectorMetadata`(`connector.getMetadata(session, txnHandle)` 只调一次)。commit/rollback → `connector.commit/rollback` → 事务移除。**无人工失效、无跨语句泄漏。** +2. **单一漏斗、单一实例**。所有元数据调用走 `Session → MetadataManager → TransactionManager → CatalogMetadata → CatalogTransaction`。**memoize 发生在管道层,不在连接器里** → 一语句一实例由**引擎**保证,与连接器自觉性无关。 +3. **事实随句柄前向流动、不靠重载**。`getTableHandle` 一次解析,快照 id/schema/分区 spec 装进不可变 `ConnectorTableHandle`,穿过 analyze/optimize/execute 直到 `beginInsert/beginMerge`。规划期从不重解析。 +4. **身份是事务级的 → 按用户隔离白拿**。`ConnectorMetadata` 用 `session.getIdentity()` 构建;`ConnectorSecurityContext = txnHandle+identity+queryId`;事务是每 session 的,故 metadata **从不跨用户共享**——隔离是事务模型的副产品。 + +**两处弱点(Doris 应超越):** +- **弱点 A**:每事务 `ConnectorMetadata` 被 memoize 了,但**加载到的 Table 对象没有**(iceberg)。`IcebergMetadata` 只缓存统计;`getTableHandle/getTableMetadata/getTableProperties` 各自重调 `catalog.loadTable()`,把去重下推到**每事务 `TrinoCatalog` 上有界可淘汰的** `tableMetadataCache`(maxSize ~1000)。**多表语句会淘汰重载 → "一语句一次加载"是软的、非结构性的**(直接坐实了"一条语句 loadTable 3–4 次靠下层缓存兜")。 +- **弱点 B**:core `CatalogTransaction` 已保证 `getMetadata` 只调一次,Hive 连接器却**又在 `HiveTransactionManager.MemoizedMetadata` 里重复实现一遍**每事务 memo。冗余耦合。 + +--- + +## 3. 目标架构(Doris 版:对齐 Trino 的不变量,并针对性超越其弱点) + +### 3.1 核心:引擎自持的"每语句/每事务 metadata 实例",memoize 在管道层 + +把今天"每次调用新建即弃"的 `ConnectorMetadata`,改成**每(语句, catalog)一个、由引擎懒建并 memoize、语句/事务结束时确定性销毁**。 + +- **宿主 = `StatementContext`**(外部写=每语句 autocommit,一 `StatementContext` ≈ 一外部事务;宿主已在,已托管 MVCC pin 与作用域,已有正确 GC/reset)。 +- **管道层单一漏斗**:在语句 span 上加 `ConnectorMetadata getOrCreateMetadata(catalogId)`,懒建、memoize、语句内所有 seam 复用同一实例——**对应 Trino 不变量②,且 memo 在引擎侧,连接器零自觉性要求**(直接规避 Trino 弱点 B:连接器不再各自手写 memo)。 +- **off-thread 可达**:实例引用像作用域一样在 `ConnectorSession` **构造期捕获**,异步 scan pump / 缓存 loader 无 thread-local 也够得到。 +- **确定性销毁**:`StatementContext.close()` 逐 catalog `metadata.close()`(对齐 Trino 的 commit/rollback 即销毁;比今天纯靠 GC 更干净)。 + +因为 `ConnectorMetadata` 今天已是"无根的临时外壳"(designNotes 原话:"除了缓存,没有任何东西把它钉在每调用"),且句柄已满足不变量③,这一步在结构上**主要是引擎侧改道 + 把缓存归属从单例挪到实例**,SPI 方法签名基本不用动(方法本就吃 `(session, handle)`)。 + +### 3.2 超越点①:结构性的"一语句一次加载"(打败 Trino 弱点 A) + +把**每语句工作集**(一次加载的 raw Table、扫描→写的删除清单桥、列句柄)从"连接器单例上的缓存 / 无类型 String→Object 作用域",**上移成每语句 metadata 实例上的字段**。 + +- iceberg 的 `IcebergStatementScope.sharedTable`/`rewritableDeleteSupply` 从"外挂 side-car"变成"实例的字段" → **加载一次成为硬的结构性质**(实例在,表就在;不是有界可淘汰缓存)。**这比 Trino 的 iceberg 更硬。** +- paimon 的胖句柄 `PaimonTableHandle.paimonTable` 溶进实例字段,句柄回归纯坐标(iceberg 已做过同款)。 + +### 3.3 超越点②:两级缓存 —— 每语句工作集 之上/之下 各司其职 + +| 层 | 位置 | 作用 | 对应 Trino | +|---|---|---|---| +| **每语句工作集** | 每语句 metadata 实例的字段 | 语句内一次加载、读写共享、扫描→写桥;**天然单用户单语句** | Trino 每事务 metadata 的缓存(但 Doris 做成硬字段) | +| **跨语句共享缓存** | Connector 单例(或专门的 caching 层),**按身份分片** | 跨语句复用;**值敏感处把 identity 放进 key** | Trino 的 `CachingHiveMetastore` / `TrinoCatalog` 缓存 | + +关键洞察(对齐 Trino 不变量④,并补 Trino 未系统化之处): + +- **正确性坍缩成一条性质**:每语句实例**天然单用户**(一语句一用户),故其内部一切缓存**永不跨用户** → 今天散落的 ~6 处门禁(`SUPPORTS_USER_SESSION` 分支、`shouldBypassTableNameCache/DbNameCache` 两钩子、`IcebergTableCache` 置 null、`IcebergCommentCache` 门禁、`IcebergStatementScope` side-car)**收敛成"实例是每语句/单用户"这一条结构性质**,删掉一整类"新加了个缓存忘了 gate"的 bug。 +- **性能补窟窿在下层**:今天 session=user/凭证目录下,跨语句缓存被**整个关掉**(`IcebergTableCache=null` 等),每条语句都为每张表多付 1 次 `loadTable` + 1–2 次 list RPC。**把下层跨语句缓存改成按身份分片(identity 进 key)** → 按用户的跨语句复用安全恢复。**这才是按用户场景真正的性能收益所在。** + +### 3.4 超越点③:把写事务并进同一个 span + +- `beginWrite` 从"重新 `loadTable`"改成"**从每语句实例取已解析的表**"(保留 openTransaction 的 refresh 兜新鲜 OCC 基底)。 +- `ConnectorTransaction` 不再是"晚建 + 绑 session"的孤儿,而是**由每语句 metadata 实例/span 持有**;commit/rollback 由 span 在语句末驱动。写路径两个 session 的割裂被收编。对齐 Trino"metadata 即事务、写方法吃句柄、commit=connector.commit(txnHandle)"。 + +### 3.5 超越点④(远期/可选):统一内外事务协调器 + +- Trino 只有外部连接器,没有"内部表"二元性;Doris 有 `GlobalTransactionMgr`(OLAP) 与 `PluginDrivenTransactionManager`(外部)两套。**把二者统一到一个事务协调面**,让外部写能参与多语句 `BEGIN..COMMIT`(span 挂 `ConnectContext`、`StatementContext` 持每语句视图,即"两级 span")——这是 Doris **特有**的自洽升级(不是"打败 Trino",是补 Doris 的割裂)。**最深、风险最高、对"达到 Trino 平价"非必需**,列为远期。 + +### 3.6 附带清理(与主线解耦,可先落) + +- **`ConnectorSession` 变廉价 + 不可变**:每语句 memoize 一次 var-map 快照,消灭 ~26 次 `VariableMgr.toMap` 反射 dump;把唯一可变字段(transaction 槽)移到 metadata/事务对象上,恢复 session 全不可变。**纯性能 + 简化,不依赖 metadata 生命周期改造,可最先落。** + +--- + +## 4. 可行性与风险 + +### 4.1 为什么可行(且比原判断容易) +- `ConnectorMetadata` 本就是每调用即弃的无根外壳 → **给它钉个语句级的家,不需打破任何单例**(最大的"想象中的拦路虎"不存在)。 +- 句柄已不可变、已 fact-carrying → **Trino 不变量③已满足**,不用重写。 +- `StatementContext` 已是被验证的 span 宿主(MVCC pin + 作用域已在其上),off-thread 可达已由"构造期捕获"解决 → **不变量①的宿主与最难的 off-thread 问题都已就位**。 +- seam 虽多但**窄而齐**:一切走 `getMetadata / getScanPlanProvider / getWritePlanProvider / beginTransaction`,fe-core 从不跨调用持有 metadata 引用(唯一例外是写执行器的 `writeOps` 字段)→ 改道是机械但可枚举的工作。 + +### 4.2 风险(诚实列出) +1. **回归面**:~40 处读热路 seam 改道,踩在刚稳定的读热缝上(PERF 系列)。→ 缓解:分期、每期加"加载计数守门"回归。 +2. **确定性销毁 vs off-thread**:实例不能在异步 scan pump 还在用时提前 close。→ 缓解:沿用作用域的"随语句 GC + 不在 close() 里清工作集"纪律,只在确证无 off-thread 引用处做确定性 close。 +3. **异步缓存 loader 无 thread-local**:schema/rowCount/stats loader 跑在别的线程、且填的是**跨语句**缓存。→ 这些本就该留在"下层跨语句缓存",每语句实例**读穿**到它们(对齐 Trino 每事务 metadata 读穿 `CachingHiveMetastore`),不强行纳入每语句实例。 +4. **Phase 4 的身份分片需威胁建模**:哪些值是用户敏感(凭证/可见性/授权)要 identity 进 key,哪些是纯元数据可共享(snapshot/format)。recon 已留两个 open 问题(session=user 下 `SchemaCacheValue` 仍开、latest-snapshot 仍跨用户共享——是否可接受需签字)。 +5. **一语句多事务?**(open Q):多目标 MERGE、或 HMS 异构网关委派兄弟连接器,是否一条语句会 mint 多个 `ConnectorTransaction`?若是,"一 StatementContext = 一事务"需放宽成"一实例/catalog"。→ 设计上按 (语句, catalog[, 目标表]) 建键即可容纳。 + +--- + +## 5. 分期(避免一次性大爆炸;每期独立可验证) + +| 阶段 | 内容 | 独立价值 | 打败 Trino? | 风险 | +|---|---|---|---|---| +| **P0 · 解耦的廉价前置** | `ConnectorSession` 每语句 memoize var-map(灭 ~26× 反射 dump);为后续把 transaction 槽移出 session 铺垫 | 纯性能 + 简化 | — | 低 | +| **P1 · 键石:引擎自持每语句 metadata 实例** | span 上加 `getOrCreateMetadata(catalogId)`,管道层 memoize,读/规划/DDL/list 全改道走它;确定性 close;off-thread 构造期捕获 | 一语句一实例(不变量①②) | 弱点 B(单漏斗、连接器零自觉) | 中(改道面广) | +| **P2 · 工作集上移 + 删 side-car** | 一次加载的 Table/扫描→写桥/列句柄 变成实例字段;删 `IcebergStatementScope` 的 String-key 用法;paimon 胖句柄溶进实例 | 结构性一次加载 | **弱点 A(硬 load-once)** | 中 | +| **P3 · 写事务并入 span** | `beginWrite` 取实例已解析表(非重载);`ConnectorTransaction` 由实例/span 持有;收编两 session 割裂 | 读写同一 span、写路径自洽 | 对齐 Trino metadata=事务 | 中-高 | +| **P4 · 按身份分片的跨语句缓存** | 下层缓存 identity 进 key;删 `SUPPORTS_USER_SESSION` 全关门禁,改成"缓存按用户分片";补 session=user 跨语句性能窟窿 | **按用户跨语句性能**(真正性能收益) | 超越 Trino 的 all-or-nothing gate | 中(需威胁建模) | +| **P5 · 统一内外事务协调器(远期)** | 一个事务面桥接 OLAP + 外部;外部写参与多语句 BEGIN..COMMIT;两级 span | 内外自洽、多语句外部事务 | Doris 特有自洽(非平价必需) | 高 | + +- **达到 Trino 平价 = P1–P3**;**超越 Trino = P2(硬 load-once)+ P4(按用户缓存)**;**P5 是远期战略,非平价必需**。 +- 建议落地顺序:P0(热身)→ P1(键石)→ P2 →(P4 与 P3 可并行/择序)→ P5 视产品需要。 + +--- + +## 6. 用户已拍板的点(2026-07-19) + +1. **终点 = 平价 + 超越(P1–P4)**;不含 P5(统一内外事务)。 +2. **首个落地单元 = 直上 P1 键石**(跳过 P0 热身)。 +3. **销毁 = 语句末确定性 close**(对齐 Trino,接受 off-thread 约束)——**但见 §8 盲区①:实际须挂在现有 query-finish 钩子,而非 `StatementContext.close`。** +4. **先跑一轮多架构师对抗红队再定稿**——已完成,结论见 §8。 + +--- + +## 7. 参考 + +- 本轮 recon+对抗验证 workflow:`wf_72d1e505-75c`(journal 在 `.../subagents/workflows/wf_72d1e505-75c/`),含 metadata 生命周期 / planner 取数路径 / 多租户缓存 / span+txn 模型 / Trino 精确机制 五组核实结论 + 4 条支撑事实的对抗判定。 +- 上一轮结论(部分被本轮 §1 更正):`recon-findings-and-trino-refactor-groundwork.md`。 +- 架构记忆:`iceberg-table-resolution-cache-scoping`(缓存作用域五纪律 + StatementContext=每语句 owner + Trino 协同)。 +- Trino 源:`InMemoryTransactionManager` / `CatalogTransaction` / `CatalogMetadata` / `MetadataManager` / `HiveMetadataFactory` / `IcebergMetadata`(master)。 + +--- + +## 8. 红队结论与定稿(多架构师对抗,workflow `wf_62cc379e-c6e`,2026-07-19) + +4 位架构师(span 中心 / 缓存中心 / 忠实 Trino / 最小面)各出一版目标架构,4 位评审(Trino 忠实度 / 迁移风险+off-thread / 多租户正确性 / 完整性)横向打分 + 找致命伤 + 挑最佳点。 + +### 8.1 排名 +| 评审轴 | 第1 | 第2 | 第3 | 第4 | +|---|---|---|---|---| +| Trino 忠实+超越 | **span (85)** | 忠实Trino (83) | 最小面 (77) | 缓存 (58) | +| 迁移+off-thread | **span (82)** | 最小面 (76) | 忠实Trino (66) | 缓存 (58) | +| 多租户正确性 | 缓存 (84) | **span (80)** | 最小面 (71) | 忠实Trino (64) | +| 完整性 | **span (64)** | 忠实Trino (61) | 最小面 (55) | 缓存 (45) | + +**胜出 = span 中心**(3/4 轴第一):复用**已验证 off-thread 可达**的现有 `ConnectorStatementScope` + `getStatementScope()` 到达路径;`connector.getMetadata` 保持纯工厂;**唯一 memo 在 fe-core 管道**(invariant 2 最纯);P1 近字节中性、可回退、风险最低。 + +> 一处被评审核实的关键点:现 `ConnectorSessionImpl` 捕获的是 **`ConnectorStatementScope` 接口**、不是 `StatementContext` 本身。故"经 getStatementScope 到达"是**被证明的**路径;而"把新管理器直接挂 StatementContext"(忠实 Trino 派原案)**不在**这条已证 off-thread 路径上,须额外补捕获——这也印证应以 span 派为骨架。 + +### 8.2 嫁接进定稿的各家最佳点 +1. **忠实 Trino 派的 `CatalogStatementTransaction`**:把"每(语句,catalog)metadata 实例"与"写事务"做成**同一个持有者**(invariant 1 最紧、P3 折叠最干净)。**弃用**其 `ConnectorSession.getMetadata(connector)` SPI(分层异味 + fallback 少测)。 +2. **缓存派的"元数据/凭证拆分"(P4 唯一正确的威胁模型)**:缓存**不含凭证的投影**(schema/snapshotId/分区 spec/授权名单)按 catalog 共享、**每请求现挂新 FileIO**;只对授权敏感投影按身份分片;**vended raw Table 一律不跨语句缓存**。身份轴 ≠ 令牌过期轴。 +3. **最小面派的**:`ConnectorSession.getIdentityShardKey()` 集中指纹(off-thread loader 复用防漂移)+ `ConnectorMetadata.close()` 默认 no-op + 扫描节点把 memoized metadata 存字段去掉 per-method 重取;`statementSession()` 消灭 ~26 次 `VariableMgr.toMap` 反射 dump(**列为独立性能项,不进 P1** 以保 P1 字节中性)。 +4. **arch/checkstyle 门禁**:禁止在 funnel 之外直接调 `connector.getMetadata`(把 invariant 2 从"约定"变"结构强制";须对 HMS sibling 布线显式放行/改道)。 + +### 8.3 五处四家都漏的公共盲区(定稿必修,①②为硬伤) +1. **🔴 确定性 close 挂错生命周期**。四家都想挂 `StatementContext.close()`/StmtExecutor finally。但每查询连接器资源释放本走 **`registerQueryFinishCallback`**(`unregisterQuery`→`finalizeQuery`,profile 等待之后=pump/BE 静默之后触发,现已用于提交 hive 读事务+放锁)。二者只在简单 autocommit 单语句下重合;**游标取数 / 转发 master / 重试 / SQL-cache 复用(只 releasePlannerResources 不 close)** 下会**过早/重复/永不**触发 → 关掉 off-thread pump 仍用的 Table/FileIO。**修:close 走现有 query-finish 钩子。** +2. **🔴 HMS 异构网关 sibling 扇出**。`HiveConnectorMetadata` 在 ~25 处 per-handle 点调 `siblingOwnerResolver.apply(handle).getMetadata(session)`,每次**在连接器内部新建 sibling metadata、funnel 看不见** → 网关一开:invariant 2 破、P2 一次加载破(sibling Table 重建 ~25 次)、catalogId 单键装不下三连接器、lint 门误报/漏保证。**修:memo 键改 `(catalogId, 属主连接器身份)`;sibling getMetadata 也路由进同一 funnel;补异构网关 e2e(记忆 `hms-iceberg-delegation-needs-e2e`)。** +3. **预编译 EXECUTE 复用泄漏**:`resetConnectorStatementScope()` 先置空不 close → P2/P3 后跨执行泄漏 FileIO/事务。**修:reset 先 closeAll 再置空。** +4. **取消/超时 reaper**:close 须栅栏在 `SplitSourceManager` 注销之后(reaper 可能 close 后才懒调 planScan),不能靠含糊"pump join"。 +5. **P4 残留泄漏(威胁模型待定)**:`latestSnapshotCache`/`partitionCache`/`formatCache` + fe-core `SchemaCacheValue` 在 session=user 下**仍开**,`beginQuerySnapshot` 命中不走 per-user loadTable → 能 list 不能 load 的用户或有 schema/snapshot 泄漏。**定 P4 前须证明无 per-user 授权态,否则按身份/快照分片或走 per-user 委派目录。** + +--- + +## 9. 定稿蓝图(P1 键石,可动工粒度) + +**骨架 = span 派;值 = 忠实 Trino 派的 `CatalogStatementTransaction`;close = 走 query-finish 钩子;键含属主连接器。** + +### 9.1 新增/改动(P1) +- **`ConnectorStatementScope`(fe-connector-api,加类型化方法)**:`ConnectorMetadata getOrCreateMetadata(MetaKey key, Supplier factory)`;`MetaKey = (catalogId, 属主连接器身份)`。`NONE` 每次跑 factory(离线/测试字节不变)。旧 `computeIfAbsent` 迁移期共存。 +- **`ConnectorStatementScopeImpl`(fe-core)**:背 `Map`;`CatalogStatementTransaction` 持 memoized `ConnectorMetadata`(+ P3 持写 `ConnectorTransaction`);提供 `closeAll()`。 +- **funnel(fe-core 静态)**:`PluginDrivenMetadata.get(connector, session)` = `session.getStatementScope().getOrCreateMetadata(key(session,connector), () -> connector.getMetadata(session))`。**~40(实测 ~64)缝**一律改调它替 `connector.getMetadata(session)`(connector+session 每处都在作用域内;扫描节点已把二者存字段,再存 memoized metadata 一字段去掉 per-method 重取)。 +- **`ConnectorMetadata.close()`**:默认 no-op(P2/P3 起连接器 override 释放 Table/FileIO/事务)。 +- **确定性 close**:在**现有 `registerQueryFinishCallback`/`unregisterQuery`** 注册 `scope.closeAll()`(pump 静默后);`resetConnectorStatementScope()` 改为**先 closeAll 再置空**;close 栅栏在 SplitSourceManager 注销之后。 +- **HMS sibling 改道**:`HiveConnectorMetadata` 的 sibling `getMetadata` 经属主连接器身份走同一 funnel(键含属主)→ 每 sibling 一实例。 +- **arch/checkstyle 门禁**:禁 funnel 外直调 `connector.getMetadata`(白名单:funnel 自身 + 明确排除的 off-thread 跨语句 loader)。 + +### 9.2 P1 终态与验证 +- 一条语句一 catalog(一属主连接器)**恰好一个 memoized `ConnectorMetadata`**,读/扫描/写/DDL 全缝复用,pump 静默后确定性 close。连接器内部零改(iceberg 的 `IcebergStatementScope` 共存于 impl,P2 再删)。 +- **P1 字节中性**:NONE 下逐次 factory=今日行为;perf delta≈0(语句内去重本就有)。收益=单漏斗 + 无 per-connector memo + 确定性生命周期。 +- **守门**:①每(语句,catalog)加载计数=1(对照 NONE=N);②跨 catalog/跨 queryId 隔离;③预编译重执行不泄漏(closeAll 被调);④异构 HMS 网关下每 sibling 一实例、加载计数=1;⑤游标取数/转发/重试/SQL-cache 路径 close 恰好一次、不早不晚(针对盲区①的回归)。 + +### 9.3 后续期(定稿方向,细化留各自立项) +- **P2**:once-loaded Table/删除桥/列句柄 → 实例字段(硬 load-once,超越弱点 A);删 `IcebergStatementScope` string-key + paimon 胖句柄。 +- **P3**:写事务并入 `CatalogStatementTransaction`;beginWrite 取实例已解析表;收编两 session。 +- **P4**:元数据/凭证拆分 + 按身份分片授权敏感投影 + 删 `SUPPORTS_USER_SESSION` 全关门禁;先解决 §8.3⑤ 残留泄漏威胁模型。 +- **独立性能项**:`statementSession()` 每语句 memoize session(灭 ~26× 反射 dump);审计所有 build 点确认 session 属性语句内恒定。 + +### 9.4 待你拍板才动代码 +本文件是设计定稿方向。**下一步(若你点头):把 §9.1 细化成 seam-by-seam 的 P1 实现设计(逐文件逐方法),仍不动代码;或直接进入 P1 实现。** 另:P4 的 §8.3⑤ 残留泄漏属安全威胁模型,建议单独走一次签字(涉及"能 list 不能 load"的元数据披露)。 diff --git a/plan-doc/per-statement-table-owner-port/progress.md b/plan-doc/per-statement-table-owner-port/progress.md new file mode 100644 index 00000000000000..941c8116b0721f --- /dev/null +++ b/plan-doc/per-statement-table-owner-port/progress.md @@ -0,0 +1,120 @@ +# Progress Log —— 每语句表加载归属者 · 移植到其它连接器 + +> **Append-only**:只追加、不覆盖(覆盖式状态在 HANDOFF/tasklist)。 + +--- + +## 2026-07-19 — session 0:建任务空间 + +- 用户拍板:把 iceberg 的 PERF-07「每语句表加载归属者」整体性改动移植到其它连接器;**本 session 只建跟踪文档空间,实际处理留下一个 session**。 +- 读准 iceberg 蓝本(PERF-07 summary):可复用地基 = 中性 `ConnectorStatementScope`(fe-connector-api) + `ConnectorSession.getStatementScope()` + `ConnectorStatementScopeImpl`/`StatementContext`/`ConnectorSessionImpl` 构造期捕获/`ExecuteCommand` 重置(fe-core),**已随 PERF-07 落地**(commit `97bdcd6bdbe`)。→ 关键结论:**移植是纯连接器侧工作,无需再改 fe-core**(不再触铁律 A)。连接器侧范式 = `IcebergStatementScope` helper + 四处 `resolveTable*` 共享 + (可选)拆胖句柄 + (可选)下沉跨臂暂存 + (可选)fail-loud。 +- 初摸候选(grep):只有 iceberg 用了该 SPI;`loadTable`/`getTable` 触点 paimon 4 / hive 3 / hudi 1 / maxcompute·es·jdbc·trino 0。→ 候选 = paimon(高)/hive-hms(中-高)/hudi(中);读-only 四家大概率排除,待复核。 +- 落地文件:`README.md`(用途 + 已就位地基 + 连接器侧 5 步模板 + 候选表 + 单连接器立项流程 + 铁律)、`tasklist.md`(PORT-01~04 全待 recon)、`HANDOFF.md`(下一步 = 确认范围 + 逐候选 recon)、`progress.md`(本文件)、`designs/`(空)。 +- **未动任何产品代码**。**下一步**:见 HANDOFF —— 下个 session 起步先与用户确认范围/顺序,再对第一个连接器 recon。 + +## 2026-07-19 — session 1:逐连接器 recon + 架构统一性调研(结论:全部 🔬 + 定高度 L0) + +- **逐连接器 recon(recon + 独立对抗复核,双签,多 agent workflow)**:结论 = **没有一个连接器现在值得移植**。 + - paimon → 🔬 **将来候选**:写未迁移(只读)、加载已被 transient 胖句柄 + SDK CachingCatalog 压到≈1;触发点=加行级 UPDATE/DELETE。 + - hive/hms → 🔬 不必做:仅 INSERT/OVERWRITE、跨查询 `CachingHmsClient.tableCache` 已兜(作用域超集);网关只做 1 次探测加载后委派兄弟(兄弟自带作用域)。 + - hudi → 🔬 不必做:只读;唯一读侧成本=未缓存 metaClient 重建,形状不对/可变/鉴权错配。 + - maxcompute·es·jdbc·trino → 🔬 排除:均无行级 DML;trino 读侧重解析最贵=另立议题占位。 + - **核心洞察**:iceberg 独特在它是唯一迁移了行级写的连接器(行级写才有多臂重复加载 + 跨臂暂存)。 +- **架构统一性专项调研(3 agent:placement / onboarding / Trino-altitude)**: + - **统一接口已存在** = 中性 `getStatementScope()` + `ConnectorStatementScope`(新连接器天生继承)。 + - **Trino 模型不可直接移植**:Trino 靠"引擎自持每事务 metadata 生命周期",Doris 连接器是共享单例、session 一条语句重建~26 次,唯一 span 宿主=`StatementContext`;现有 SPI 已是最可移植高度。 + - **高度分级 L0/L1/L2/L3**;**paimon-加写=L1 抽共享 helper 的正确触发点**(有 2 个真实用户);共享 helper 落 `fe-connector-api`(不碰铁律 A)。 +- **用户拍板**:先把结论记成**单独文档** → `designs/recon-findings-and-trino-refactor-groundwork.md`;**重构成 Trino 架构(L2/L3)留下个 session 专题讨论**(该文档 §7 已备预备材料)。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 下个 session = 讨论"重构成 Trino 架构"的可行性/分期/与铁律 A 的取舍。 + +## 2026-07-19 — session 2:Trino 式重构专题(用户豁免铁律)→ 目标架构定稿方向 + 红队 + +- **用户新指令**:抛开一切铁律/约束,可改任何模块,目标=至少和 Trino 一样合理、能更好则更好。 +- **workflow ①(`wf_72d1e505-75c`)现状+Trino recon + 对抗验证**:更正旧文档——`ConnectorMetadata` 是**每调用即弃外壳**(`Connector` 才是单例),改每语句不用砸单例;句柄已 fact-carrying(Trino invariant 3 已满足);`StatementContext` 已是被验证的 span 宿主(off-thread 靠构造期捕获);外部写=每语句 autocommit(1 StatementContext≈1 外部事务);Trino 四不变量 + 两弱点(iceberg 软 load-once / 冗余双 memo)源码核实。 +- **定稿设计** → `designs/trino-parity-metadata-redesign-design.md`:目标架构=引擎自持每语句 metadata 实例 + 管道层唯一 memo + 下层按身份分片(拆凭证)缓存。 +- **用户拍板**:终点 P1–P4(不含 P5);直上 P1 键石;语句末确定性 close;先红队。 +- **workflow ②(`wf_62cc379e-c6e`)4 架构师 × 4 评审对抗红队**:胜出=span 派(3/4 轴第一);嫁接忠实-Trino 的 `CatalogStatementTransaction` + 缓存派"元数据/凭证拆分" + 最小面派若干硬化 + arch 门禁。**揪出五处公共盲区(两硬伤)**:①close 须走现有 `registerQueryFinishCallback` 而非 StatementContext.close;②HMS sibling 扇出须键含属主连接器 + sibling 路由进 funnel;③EXECUTE reset 先 closeAll;④close 栅栏在 SplitSourceManager 注销后;⑤P4 残留泄漏威胁模型待签字。已并入 §8/§9。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 等用户点头后细化 P1 seam-by-seam 实现设计或直接进入 P1 实现。 + +## 2026-07-19 — session 2(续):P1 seam-by-seam 实现设计(grounding + 定稿) + +- **workflow ③(`wf_7e537094-44f`)只读 grounding**(5 读者:seam 清单 / close 生命周期 / HMS sibling / scope-session SPI / 测试+门禁基建)。核实产出:**66 处真实 seam**(改道表);close 钩子=`registerQueryFinishCallback`(须移注册点到 scope 创建、closeAll 幂等);**HMS 网关已 LIVE**(2026-07-17 进 SPI_READY_TYPES,dormant 注释过期,三连接器共享 catalogId→key 加属主 label);`ConnectorMetadata` 已 Closeable no-op(memoize 安全);守门/门禁全有模板。 +- **定稿** → `designs/P1-implementation-design.md`:§1 SPI/plumbing 签名、§2 66 缝改道、§3 扫描节点存字段、§4 close 接线、§5 HMS sibling funnel、§6 read-vs-write(写侧 7 缝留 P3)、§7 守门、§8 arch 门禁、§9 四个待确认点、§10 commit 切分 C1–C5。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 等用户确认 §9 四点后进入 P1 实现(C1→C5)。 + +## 2026-07-19 — session 3:扩展范围取证 + 用户二次拍板 → 定稿分期 + +- **用户就 §9 四点拍板,偏离两处原推荐**:写侧要一起做、后台加载点"传句柄到后台线程"、缓存隔离现在就做。 +- **workflow(`wf_8b907b93-e9f`,18 agents,recon+对抗+综合)** 三块结论: + - **读写共用**:可行且忠实 Trino(`CatalogTransaction` 单实例读写共用已核实);机制=`CatalogStatementTransaction` 共持体 + 写臂 8 处改道;两闸门=按连接器保留起写刷新(hive 留 beginWrite getTable)+ 读写身份指纹相等。 + - **后台传递(用户方向被证伪)**:把实例传后台不安全(共享池 worker 复用 → 已关闭对象被无关查询误用);纠正=语句派生异步"提交时捕获"+ 7 处跨语句 loader 显式强制 NONE 读穿;**查出并建议修 `fetchRowCount` 在 ANALYZE 线程被错误绑定的真实隐患**。 + - **缓存隔离**:影响面小(仅 iceberg 三投影缓存 + fe-core 表结构缓存);两轴切分(授权敏感投影按身份分片、含凭证原始表永不跨语句缓存)+ 新 SPI `getIdentityShardKey()`;HMS 异构网关是最尖的洞。 + - **综合**:强烈建议**分期**(硬依赖 + 后台传递被证伪 + 缓存隔离是独立安全工程;一次性全做=不可二分回归面)。 +- **用户二次拍板(动码前)**:①**分期推进**;②后台**采纳纠正做法 + 修隐患**;③**确认 list ≠ load** → 缓存隔离=真实越权修复,独立安全 track。 +- **定稿** → `designs/expanded-scope-phasing-and-security-decisions.md`:决策表 + 三块取证 + 分期 STEP 1(读取键石,含后台纠正)/ STEP 2(HMS 兄弟)/ STEP 3(写入共用,拆 3a/3b)/ STEP 4(缓存隔离,独立安全 track)+ 对旧文档更正。 +- **未动任何产品代码**(纯 plan-doc)。**下一步**:见 HANDOFF —— 等用户对 STEP 1 逐提交方案点头即进入实现(C1→C3 = 键石 + 后台纠正)。 + +## 2026-07-19 — session 3(续):落地 STEP 1 的 C1 地基 + C2 关闭接线 + +- 用户逐提交确认后开写。**首次改动产品代码**(用户已豁免铁律 A)。 +- **C1 地基**(commit `5b7312f9d1f`):`ConnectorStatementScope` 加 `getOrCreateMetadata`/`closeAll` 默认方法;`ConnectorStatementScopeImpl.closeAll` 幂等 close-once + best-effort;新增静态漏斗 `PluginDrivenMetadata.get(session, connector)`(按 catalogId memoize `connector.getMetadata`)。fe-core 单测 memo-once/NONE-each/跨目录隔离/关一次。字节中性(无生产路径接进漏斗)。编译 + checkstyle 0 + 8 测全绿。 +- **动手 C2 前的取证**(workflow `wf_9250330b-e81`):发现 `captureStatementScope` 在**每次 on-thread 会话构建**就急切建 scope;原设计"在 scope 创建处注册关闭回调"**会泄漏**——DDL/`SHOW`/`DESCRIBE`/`EXPLAIN`/前台 `ANALYZE` 走 `Command.run` 建 scope 却永不到 `unregisterQuery`,回调连带 scope 永留无 TTL 注册表。已对抗复核。 +- **C2 关闭接线**(commit `12f3e95239b`):改为**两层关闭**——主关闭 `PluginDrivenScanNode.getSplits` 注册 `scope::closeAll`(对象捕获、跳 NONE、同 read-txn queryId 键,只对走协调器语句触发、pump 静默后);兜底 `StatementContext.close()` 的 `isReturnResultFromLocal` 守卫 closeAll(直连 `executeQuery` finally + `proxyExecute` 新增 finally 覆盖转发主节点);`resetConnectorStatementScope` 改 closeAll-before-null;`handleQueryWithRetry` 每重试重置。核实 `releasePlannerResources` 幂等故转发路径补 close 安全。单测 reset-先关/close-本地关/close-异步延后。P1 关闭仍 no-op,行为中性。编译 + checkstyle 0 + 11 测全绿。 +- 更正 `P1-implementation-design.md` §4(旧"scope 创建处注册"→两层关闭);残留风险(取消非硬栅栏、arrow-flight 断连、待确认的非-getSplits 外部查询、**TCCL 自钉扎硬前置**)记入分期定稿 §6。 +- **下一步**:见 HANDOFF —— 下个 session 做 C3(读取侧改道 ~55 缝 + 扫描节点存字段 + 7 处后台读穿纠正 + 修 fetchRowCount 隐患),动手前先核行号列清单 + 配对抗复核。用户将在下个 session 开新任务。 + +## 2026-07-19 — session 4:落地读取侧改道 + 后台读穿纠正(读取键石收官主体) + +- **动手前核对关(对抗复核 workflow `wf_6e2967a9-1a2`,10 agents:7 逐文件普查 + 3 对抗验证)**:把改道表逐条对当前代码行号重核。结论落 `designs/C3-read-reroute-verified-checklist.md`:fe-core 连接器 `getMetadata` 工厂缝**共 66 处**,完整切成四类,相加=66、零遗漏/零重叠/零未分类;设计命名行号零漂移(仅扫描节点尾部 +13 行位置漂移)。对抗复核 CONFIRM 写专用点 `resolveWriteTargetHandle` 唯一生产调用方是插入执行器开事务、读路径够不到,必须留后续。 +- **两处偏差交用户拍板 + 拍定**:①名字映射两缝(`fromRemoteDatabaseName`/`fromRemoteTableName`)经复核跑在离请求线程的缓存加载路径 → 归入强制 NONE 读穿(读穿 9 处、改道 49 处);②读穿 loader 走统一入口 + 传 NONE 会话(门禁零例外)。 +- **落地(首次改产品代码,未提交前全绿)**:新增 `PluginDrivenExternalCatalog.buildCrossStatementSession()`(镜像 buildConnectorSession + 强制 NONE,保留凭证);9 处后台 loader 改用它并走统一入口(含 `fetchRowCount` ANALYZE 隐患修复——不必单改统计模块);49 处读/DDL/命令/多版本改道进统一入口;扫描节点加 `volatile cachedMetadata` + `metadata()` 访问器(静态 create 直调入口)。源码证实「强制 NONE 走统一入口」与裸直调**字节等价**(NONE 每次跑工厂、零留存)。 +- **测试适配(workflow `wf_fbb60841-365`,11 agents 并行修各文件)**:改道后这些路径经统一入口调 `session.getStatementScope()`,Mockito mock 默认返 null → 漏斗 NPE;按 C1 约定(测试给 NONE 作用域)逐文件补 `getStatementScope()→NONE` stub + 给测试替身补 `buildCrossStatementSession` override(不弱化任何断言/verify)。新增守门 `ConnectorSessionImplTest.explicitNoneStatementScopeWinsOverLiveContext`(活 ctx 下显式 NONE 胜出,锁 ANALYZE 隐患修复的机制)。 +- **验证**:目标测试 247 全绿(先红 130 error+30 fail → 修后 0/0)+ ConnectorSessionImplTest 18 绿 + fe-core checkstyle BUILD SUCCESS 零违规 + fe-core 主编译 BUILD SUCCESS。 +- **提交口径**:4 个逻辑子步(助手/读穿/改道/扫描存字段)在 2 个共享文件里交织,且每提交须保持绿(测试适配须随产品改动同提),逐 hunk 拆分需交互式 git(本环境不支持)→ 按 C1/C2 先例=1 个绿代码提交(含产品+测试适配+守门)+ 1 个文档提交,代码提交体内枚举 4 子步。 +- **下一步**:见 HANDOFF —— 读取键石剩防漂移门禁(形态=统一入口零例外);下一大步 = HMS 异构网关兄弟扇出(键含属主 label + 兄弟 getMetadata/起事务进同一入口 + 异构网关 e2e)。 + +## 2026-07-19 — session 5:防漂移门禁落地(读取键石完全收官) + +- **用户拍板(动码前,方案 A)**:现在就上门禁锁死读取侧、写入 8 处显式豁免(写入共用步骤再收);否决"推迟整个门禁到写入步骤后一次落"。 +- **动手前取证**:对当前树 grep `.getMetadata(` 全域 = 21 行 = 9 处无参(`TestExternalCatalog`×8 静态 Map + `RuntimeProfile.node.getMetadata()`,均 `src/main`)+ 3 处 funnel javadoc + 1 处 funnel 真调 + **8 处写入裸调**。读取侧已"零裸调"(49 处 C3 已改道走 `PluginDrivenMetadata.get`)。 +- **落地(commit `b2d147998d1`)**: + - 新增 `tools/check-fecore-metadata-funnel.sh`(bash grep 门禁,仿 `check-connector-imports.sh`)+ 自测 `.test.sh`。规则:扫 `fe/fe-core/src/main/java`,禁裸 `Connector#getMetadata(session)`;**放行**=①funnel 文件 `datasource/plugin/PluginDrivenMetadata.java`(含其 javadoc)②带 `getMetadata-funnel-exempt` 标记的行(**call 行或其上一行**,兼容长行)③无参 `getMetadata()`(异方法)④注释行。正则双形匹配(同行参数 + 换行到下一行的参数),锚定调用形不误伤 `getMetadataTableRows`/API 定义。 + - 8 处写入裸调各加一行上置标记注释(103 字,全 <120):PhysicalPlanTranslator×2 / BindSink×2 / PluginDrivenInsertExecutor / IcebergRowLevelDmlTransform / PhysicalIcebergMergeSink / PluginDrivenExternalTable(resolveWriteTargetHandle)。删标记即自动收紧到该处。 + - 挂入 `fe/fe-core/pom.xml` validate 阶段 exec(`${project.basedir}/../../tools/...`,与 fe-connector 门禁同深度同范式)。 +- **验证**:自测 PASS(含核心裸调、换行参数、funnel 白名单、同行/上行标记、无参跳过、`getMetadataTableRows` 边界、注释跳过、退出码、标记可承重 10 项);门禁对真实树 exit 0,对 8 处未标记 exit 1(都证过);fe-core checkstyle 0 违规;`mvn -pl fe-core validate` 实跑触发门禁 exec + BUILD SUCCESS。 +- **读取键石(RD-1 / STEP 1)至此完全收官**:C1 地基 + C2 关闭 + C3 改道 + 扫描存字段 + 后台读穿 + 防漂移门禁全落地。 +- **未提交的第三方无关文件不动**(stray untracked `fe/.mvn/maven.config` 等非本轮产物,只 stage 本轮 9 文件)。 +- **下一步**:见 HANDOFF —— 下一大步 = HMS 异构网关兄弟扇出(RD-2 / STEP 2),动码前先按分期定稿 §1/§2 + P1-design §5 对当前代码做 grounding recon 并把方案用中文详述待用户确认。 + +## 2026-07-19 — session 6:HMS 异构网关兄弟元数据每语句去重落地(RD-2 主体) + +- **动码前设计先行 + grounding**(workflow `wf_62fa5a7f-07a`,5 读者 + 1 对抗完整性核验,并自行 clean-room 读码交叉核对)。核实结论比文档预想**小很多、也更集中**:整个 fe-connector-hive 模块"取兄弟元数据"仅 **4 处**(3 个 helper `icebergSiblingMetadata`/`hudiSiblingMetadata` by-TYPE + `siblingMetadata` by-HANDLE,加 `getTableSchema` 旁路);文档"~43 处 per-handle 改道"实为误导——那 40+ 处 per-handle 转发 + `beginTransaction(session,handle)` 全穿第三个 helper,**改动零行**。catalogId 经 `session.getCatalogId()` 可达(无需新布线);连接器侧直用 `session.getStatementScope().getOrCreateMetadata`(不 import fe-core,且该 key 形态早在 SPI 注释预声明)。 +- **中文方案交用户确认**(不引任务代号):4 处收口 + 属主标签从解析器**命中臂**取(拒绝会 force-build 的 supplier 身份比对——否则 hudi-only 目录会平白建 iceberg 兄弟)+ 旁路收回 + beginTransaction 免费覆盖 + closeAll 免额外接线。用户确认整体方案;**e2e 时机拍板 = 随后续统一补**(本步只做连接器单测锁死机制,异构网关 e2e 留切换阶段统一补,对齐 `hms-iceberg-delegation-needs-e2e`)。 +- **落地(commit `5fd55d0a32a`)**:新增 `SiblingOwner{connector,label}`(`ICEBERG_LABEL`/`HUDI_LABEL` 常量作单一真源);`HiveConnector.resolveSiblingOwnerLabeled` 命中臂带标签、`resolveSiblingOwner` 委派它(3 个 provider seam 字节不变);`HiveConnectorMetadata.memoizedSiblingMetadata` key=`metadata::