diff --git a/fe/fe-connector/fe-connector-api/pom.xml b/fe/fe-connector/fe-connector-api/pom.xml index d24ec0d3b02bef..ecf566a7f428ee 100644 --- a/fe/fe-connector/fe-connector-api/pom.xml +++ b/fe/fe-connector/fe-connector-api/pom.xml @@ -33,9 +33,15 @@ under the License. jar Doris FE Connector API - Consumer-facing API for the Doris FE connector abstraction layer. + The interfaces a Doris FE connector plugin implements, and the engine consumes. Contains the core Connector interface, ConnectorMetadata sub-interfaces, opaque Handle types, ConnectorSession, value objects, and capability enums. + Design rules for the whole connector surface are documented in this module's + org.apache.doris.connector.api package-info. + + Note that despite the module names, this is the module a connector implements; + fe-connector-spi holds the plugin entry point plus the engine services a + connector consumes. The fe-thrift dependency (provided scope) allows connectors to construct typed TTableDescriptor objects for the FE-BE protocol. At runtime the diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java index e1bdc83c088324..17f47e86722f6f 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java @@ -19,15 +19,14 @@ import org.apache.doris.connector.api.event.ConnectorEventSource; import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.handle.WriteOperation; import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; +import org.apache.doris.connector.api.rest.ConnectorRestPassthrough; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import java.io.Closeable; import java.io.IOException; import java.util.Collections; -import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.OptionalLong; @@ -38,10 +37,28 @@ * *

A {@code Connector} instance is created once per catalog and provides * access to metadata, scan planning, and optional write operations.

+ * + *

This interface does not mirror any provider's switches, and must not start. A subsystem trait + * (write operations, parallel write, partition-hash write, ...) is declared on the provider that owns it and + * read from there; a forwarding copy here would be a second overridable answer to one question, and a + * connector overriding the copy while leaving the provider at its default would produce two divergent + * answers with no compile error and no failing test. The engine reaches a trait by fetching the provider + * ({@link #getWritePlanProvider(ConnectorTableHandle)} for a per-table answer, {@link #getWritePlanProvider()} + * for a connector-wide one) and asking it, treating a {@code null} provider as "not supported".

+ * + *

The getters that return {@code null} ({@code getScanPlanProvider}, {@code getWritePlanProvider}, + * {@code getProcedureOps}, {@code getEventSource}, {@code getRestPassthrough}) are the opposite case: those + * ARE the declaration points for "this subsystem exists". See the {@code org.apache.doris.connector.api} + * package documentation for the full rule.

*/ public interface Connector extends Closeable { - /** Returns the metadata interface for the given session. */ + /** + * Returns the metadata interface for the given session. The engine calls this exactly once per catalog per + * statement through its own single entry point and closes the result when the statement ends, so an + * implementation may return a fresh, statement-scoped object; see {@link ConnectorMetadata} for the + * lifecycle contract. + */ ConnectorMetadata getMetadata(ConnectorSession session); /** @@ -107,84 +124,6 @@ default ConnectorWritePlanProvider getWritePlanProvider(ConnectorTableHandle han return getWritePlanProvider(); } - /** - * The write operations the engine may perform on this connector — the single admission source. Reads the - * write provider's {@link ConnectorWritePlanProvider#supportedOperations()}; no provider ⇒ empty set ⇒ all - * writes rejected. The engine consults this instead of {@code getWritePlanProvider() != null}. - */ - default Set supportedWriteOperations() { - ConnectorWritePlanProvider p = getWritePlanProvider(); - return p == null ? EnumSet.noneOf(WriteOperation.class) : p.supportedOperations(); - } - - /** - * Per-table view of {@link #supportedWriteOperations()}: derives from {@link #getWritePlanProvider( - * ConnectorTableHandle)} so a heterogeneous gateway admits the right operations for {@code handle} (e.g. an - * iceberg-on-HMS table admits DELETE/MERGE that the hive provider does not). The default routes through the - * per-handle provider, so every single-format connector is unaffected. - */ - default Set supportedWriteOperations(ConnectorTableHandle handle) { - ConnectorWritePlanProvider p = getWritePlanProvider(handle); - return p == null ? EnumSet.noneOf(WriteOperation.class) : p.supportedOperations(); - } - - /** Null-safe view of {@link ConnectorWritePlanProvider#supportsWriteBranch()}. No provider ⇒ false. */ - default boolean supportsWriteBranch() { - ConnectorWritePlanProvider p = getWritePlanProvider(); - return p != null && p.supportsWriteBranch(); - } - - /** Per-table view of {@link #supportsWriteBranch()} (derives from the per-handle provider). */ - default boolean supportsWriteBranch(ConnectorTableHandle handle) { - ConnectorWritePlanProvider p = getWritePlanProvider(handle); - return p != null && p.supportsWriteBranch(); - } - - /** Null-safe view of {@link ConnectorWritePlanProvider#requiresParallelWrite()}. No provider ⇒ false. */ - default boolean requiresParallelWrite() { - ConnectorWritePlanProvider p = getWritePlanProvider(); - return p != null && p.requiresParallelWrite(); - } - - /** Null-safe view of {@link ConnectorWritePlanProvider#requiresFullSchemaWriteOrder()}. No provider ⇒ false. */ - default boolean requiresFullSchemaWriteOrder() { - ConnectorWritePlanProvider p = getWritePlanProvider(); - return p != null && p.requiresFullSchemaWriteOrder(); - } - - /** Null-safe view of {@link ConnectorWritePlanProvider#requiresPartitionLocalSort()}. No provider ⇒ false. */ - default boolean requiresPartitionLocalSort() { - ConnectorWritePlanProvider p = getWritePlanProvider(); - return p != null && p.requiresPartitionLocalSort(); - } - - /** Null-safe view of {@link ConnectorWritePlanProvider#requiresPartitionHashWrite()}. No provider ⇒ false. */ - default boolean requiresPartitionHashWrite() { - ConnectorWritePlanProvider p = getWritePlanProvider(); - return p != null && p.requiresPartitionHashWrite(); - } - - /** Per-table view of {@link #requiresPartitionHashWrite()} (derives from the per-handle provider). */ - default boolean requiresPartitionHashWrite(ConnectorTableHandle handle) { - ConnectorWritePlanProvider p = getWritePlanProvider(handle); - return p != null && p.requiresPartitionHashWrite(); - } - - /** - * Null-safe view of {@link ConnectorWritePlanProvider#requiresMaterializeStaticPartitionValues()}. No - * provider ⇒ false. - */ - default boolean requiresMaterializeStaticPartitionValues() { - ConnectorWritePlanProvider p = getWritePlanProvider(); - return p != null && p.requiresMaterializeStaticPartitionValues(); - } - - /** Per-table view of {@link #requiresMaterializeStaticPartitionValues()} (derives from the per-handle provider). */ - default boolean requiresMaterializeStaticPartitionValues(ConnectorTableHandle handle) { - ConnectorWritePlanProvider p = getWritePlanProvider(handle); - return p != null && p.requiresMaterializeStaticPartitionValues(); - } - /** * Returns the procedure ops for {@code ALTER TABLE EXECUTE} dispatch, or {@code null} if this * connector exposes no table procedures. Procedure-side analogue of {@link #getWritePlanProvider()}. @@ -214,10 +153,10 @@ default Set getCapabilities() { /** * Storage-configuration defaults this connector derives from its own catalog properties, which the raw - * catalog map does not already supply. Design S8: storage-property derivation is owned by the connector — + * catalog map does not already supply. Storage-property derivation is owned by the connector — * fe-core does not parse metastore properties. fe-core folds the returned map into the catalog's storage * properties as DEFAULTS (an explicit user key always wins via {@code putIfAbsent}), and does so BEFORE - * both the fe-filesystem bind ({@code ConnectorContext.getStorageProperties()}) and the BE storage map + * both the fe-filesystem bind ({@code ConnectorStorageContext.getStorageProperties()}) and the BE storage map * ({@code getBackendStorageProperties()}), so the FE bind and the BE scan see the same derived storage. * *

The default is empty (no derivation), so every connector that does not need it is unaffected. The @@ -231,16 +170,6 @@ default Map deriveStorageProperties(Map rawCatal return Collections.emptyMap(); } - /** Returns the table-level property descriptors. */ - default List> getTableProperties() { - return Collections.emptyList(); - } - - /** Returns the session-level property descriptors. */ - default List> getSessionProperties() { - return Collections.emptyList(); - } - /** * Returns whether connectivity testing should be enabled by default when * the user does not explicitly set the {@code test_connection} property. @@ -289,21 +218,6 @@ default ConnectorTestResult testConnection(ConnectorSession session) { default void close() throws IOException { } - /** - * Execute a REST passthrough request against the underlying data source. - * - *

Connectors that expose HTTP endpoints (e.g., Elasticsearch) can - * override this to proxy REST requests from FE REST APIs.

- * - * @param path the relative URL path (e.g., "index_name/_search") - * @param body the request body (may be null for GET-style requests) - * @return the response body as a JSON string - * @throws UnsupportedOperationException if the connector doesn't support REST - */ - default String executeRestRequest(String path, String body) { - throw new UnsupportedOperationException("REST passthrough not supported by this connector"); - } - /** * Invalidates any connector-side per-table cache (e.g. a latest-snapshot/version cache) so a subsequent * read reflects the latest external state. Called by the engine on {@code REFRESH TABLE}. The names are @@ -348,6 +262,17 @@ default ConnectorEventSource getEventSource() { return null; } + /** + * Returns this connector's HTTP passthrough capability, or {@code null} if it has none. A capability-probe + * getter with the same shape as {@link #getEventSource()}: the caller probes for {@code null}, never via + * {@code instanceof}. Consumed by FE HTTP endpoints that speak one source's HTTP dialect (today + * {@code ESCatalogAction}), which narrow to that catalog type first and only then ask for the capability. + * The default returns {@code null}, so no connector inherits an entry point it cannot serve. + */ + default ConnectorRestPassthrough getRestPassthrough() { + return null; + } + /** * Optional per-connector override of the catalog's schema-cache TTL (in seconds), consulted generically by * the engine when sizing the schema meta-cache. Semantics match {@code schema.cache.ttl-second}: diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorCapability.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorCapability.java index 44f83aa6c7155d..53effc43157776 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorCapability.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorCapability.java @@ -26,25 +26,49 @@ * sort, full-schema write order, static-partition materialization) are NOT declared here — * they live on the connector's {@link org.apache.doris.connector.api.write.ConnectorWritePlanProvider} * instead, surfaced via {@link Connector#getWritePlanProvider()}.

+ * + *

Two resolution scopes

+ * + *

Most of these the engine resolves once per CATALOG, from {@link Connector#getCapabilities()}. + * Five it resolves per TABLE, as the union of the catalog-wide set and that table's own + * {@link ConnectorTableSchema#getTableCapabilities()} — which is what lets a heterogeneous connector + * (hive: orc/parquet/text/json/view/hudi in one catalog) admit only the tables that qualify. Every + * constant below states its scope. Putting a catalog-scoped capability in a table's set is silently + * ignored, so the distinction is part of the contract, not an implementation detail.

+ * + *

The split is not arbitrary. A capability can be table-scoped only if, at the moment the engine asks, + * (a) there IS a table, and (b) reading that table's cached schema is affordable. Three of the + * catalog-scoped ones fail (a) — the answer picks the table subclass before the table object exists, + * or feeds a table-valued function, or gates a CREATE TABLE clause for a table that does not exist yet. + * Two fail (b): they are consulted from inside table initialization, or in order to decide whether to + * load metadata at all, so reading the schema cache there would invert the order and force a remote + * round-trip per table. The remaining ones are catalog-scoped because two call sites must agree, or + * because no consumer needs the refinement. Widening any of them to table scope is a reviewable change, + * not a mechanical one.

*/ public enum ConnectorCapability { - SUPPORTS_MVCC_SNAPSHOT, /** - * Indicates the connector supports passthrough query via the {@code query()} TVF. + * Indicates the connector exposes a point-in-time snapshot of a table (MVCC), so its tables can serve + * time travel and back a materialized view's freshness tracking. * - *

Connectors declaring this capability must implement - * {@link ConnectorTableOps#getColumnsFromQuery} to provide column metadata - * for arbitrary SQL queries passed through to the remote data source.

+ *

Scope: catalog-wide only. The engine reads it to choose WHICH TABLE SUBCLASS to instantiate, + * i.e. strictly before the table object exists; a table-scoped answer would have to come from that + * table's schema, which cannot be reached without the table.

*/ - SUPPORTS_PASSTHROUGH_QUERY, + SUPPORTS_MVCC_SNAPSHOT, /** * Indicates the connector exposes per-partition statistics (record count, on-disk size, - * file count) via {@link ConnectorTableOps#listPartitions}. + * file count) via {@link ConnectorPartitionListingOps#listPartitions}. * *

{@code SHOW PARTITIONS} renders a rich multi-column result (Partition / PartitionKey / * RecordCount / FileSizeInBytes / FileCount) for connectors declaring this capability, instead * of the single partition-name column used by connectors that only implement * {@code listPartitionNames}.

+ * + *

Scope: catalog-wide only. Two call sites must return the SAME answer — one decides how many + * columns each result row carries, the other how many column headers to declare — and the header path + * has no resolved table in hand. A per-table answer risks a row width that disagrees with its header, + * which is a visibly wrong result rather than a missed optimization.

*/ SUPPORTS_PARTITION_STATS, /** @@ -57,6 +81,9 @@ public enum ConnectorCapability { * unimplemented for external SQL-driven tables ({@code ExternalAnalysisTask.doSample} throws). * Row/passthrough connectors that cannot serve per-column statistics (e.g. JDBC, ES) must NOT * declare it so they stay excluded.

+ * + *

Scope: catalog-wide OR per-table. hive declares it per-table for its plain-hive data tables + * only (legacy gated on the table being plain hive, so an embedded hudi table stays out).

*/ SUPPORTS_COLUMN_AUTO_ANALYZE, /** @@ -68,6 +95,9 @@ public enum ConnectorCapability { * for a plugin-driven table only when its connector declares this (replacing the legacy exact-class * {@code SUPPORT_RELATION_TYPES} membership of {@code IcebergExternalTable}). Row/passthrough * connectors (e.g. JDBC, ES) must NOT declare it.

+ * + *

Scope: catalog-wide OR per-table. hive declares it per-table because eligibility is + * orc/parquet-only, which it cannot express for a catalog that also holds text/json tables.

*/ SUPPORTS_TOPN_LAZY_MATERIALIZE, /** @@ -80,17 +110,27 @@ public enum ConnectorCapability { * Row/passthrough connectors whose {@code getTableProperties()} returns connection properties * including credentials (e.g. JDBC, ES) must NOT declare it, or SHOW CREATE TABLE would leak * the connection password — the security control the legacy engine-name gate provided.

+ * + *

Scope: catalog-wide only. Nothing needs the refinement — property safety is a property of the + * connector, not of one of its tables — and since this doubles as the credential-leak guard, moving it to + * table scope would put a security decision behind a per-table value. Widening it needs its own review.

*/ SUPPORTS_SHOW_CREATE_DDL, /** * Indicates the connector exposes views as queryable objects distinct from tables. * *

When a connector declares this, a plugin-driven table resolves its {@code isView()} from the - * connector ({@link ConnectorTableOps#viewExists}) instead of the {@code false} default, the catalog - * merges the connector's {@link ConnectorTableOps#listViewNames} back into {@code SHOW TABLES} (iceberg + * connector ({@link ConnectorViewOps#viewExists}) instead of the {@code false} default, the catalog + * merges the connector's {@link ConnectorViewOps#listViewNames} back into {@code SHOW TABLES} (iceberg * subtracts views from {@code listTableNames}), and the read/DML/SHOW CREATE arms treat the object as a * view. Connectors with no view concept (e.g. JDBC, ES) must NOT declare it so every table stays * {@code isView()==false} and no view round-trips are issued.

+ * + *

Scope: catalog-wide only. The engine asks this from INSIDE table initialization (resolving + * {@code isView()} is part of initializing the table) and also while merely listing names. A table-scoped + * answer would have to be read from that table's cached schema, so every table in every plugin catalog + * would trigger a schema load just to learn it is not a view — an order inversion that turns a free + * in-memory check into one remote round-trip per table.

*/ SUPPORTS_VIEW, /** @@ -107,6 +147,10 @@ public enum ConnectorCapability { * nested leaves by id — an un-translated (name / {@code -1}) leaf is skipped and returns NULL. Row/ * passthrough connectors (e.g. JDBC, ES) and connectors that do not carry nested field ids must NOT * declare it.

+ * + *

Scope: catalog-wide OR per-table. hive declares it per-table because eligibility is + * orc/parquet-only; blanket-declaring it for a mixed catalog would be a correctness bug, not just an + * over-admission — a text/json table has no field ids, so pruned leaves would read back NULL.

*/ SUPPORTS_NESTED_COLUMN_PRUNE, /** @@ -121,6 +165,10 @@ public enum ConnectorCapability { * pure planning/lock-latency optimization with no correctness effect: connectors whose metadata reads are * cheap or not yet validated for concurrent pre-warming (e.g. ES) simply do not declare it and fall back * to synchronous load at binding time.

+ * + *

Scope: catalog-wide only. Its sole consumer asks it in order to decide whether to load this + * table's metadata at all, so a table-scoped answer — which lives in that table's cached schema — would + * mean loading the metadata to find out whether to load the metadata.

*/ SUPPORTS_METADATA_PRELOAD, /** @@ -136,6 +184,9 @@ public enum ConnectorCapability { * user's REST-authorized/vended view is never served to another (cross-user leakage). Connectors that * authenticate with a single static catalog identity (every non-REST iceberg flavor, JDBC, ES, ...) must * NOT declare it. Declared by the iceberg connector only when configured {@code iceberg.rest.session=user}.

+ * + *

Scope: catalog-wide only. It is a property of how the catalog authenticates, and it is read + * while BUILDING the session — before any table is named, let alone loaded.

*/ SUPPORTS_USER_SESSION, /** @@ -144,10 +195,12 @@ public enum ConnectorCapability { * Doris-type slot-width math). * *

fe-core admits sampled analyze for a plugin-driven table only when it declares this. A heterogeneous - * connector (hive) emits it as a PER-TABLE marker in getTableSchema for its plain-hive tables only (legacy + * connector (hive) declares it PER-TABLE in getTableSchema for its plain-hive tables only (legacy * gated on {@code dlaType==HIVE}), so iceberg/hudi-on-HMS are excluded. Connectors whose {@code doSample} is * unimplemented (native iceberg/paimon, JDBC, ES) must NOT declare it so sampled analyze stays rejected at * build time.

+ * + *

Scope: catalog-wide OR per-table.

*/ SUPPORTS_SAMPLE_ANALYZE, /** @@ -161,6 +214,9 @@ public enum ConnectorCapability { * duplicates) inside its own {@code createTable}. This is a DDL-clause gate and is distinct from the runtime * sink trait {@code ConnectorWritePlanProvider.requiresFullSchemaWriteOrder()}, which governs how rows are * ordered on the write path, not whether the CREATE TABLE DDL accepts the clause.

+ * + *

Scope: catalog-wide only. It gates a clause of the statement that CREATES the table, so the + * table it would be refined against does not exist when the question is asked.

*/ SUPPORTS_SORT_ORDER, /** @@ -172,12 +228,13 @@ public enum ConnectorCapability { * schema-change clause set (nested paths, the {@code MODIFY COLUMN COMMENT} op) for a plugin-driven * table only when its connector declares this (replacing the legacy exact-class {@code instanceof * IcebergExternalTable} gate). The actual mutation is routed through {@code PluginDrivenExternalCatalog}'s - * {@code ColumnPath} column-DDL overrides into the connector's {@link ConnectorTableOps} column-evolution - * ops. Resolved per-table via {@code hasScanCapability} so an iceberg-on-HMS table (whose catalog - * connector is hive) inherits it through the reflected per-table capability set, exactly like - * {@link #SUPPORTS_NESTED_COLUMN_PRUNE}. Connectors without column schema-change support (JDBC, ES, - * paimon/maxcompute today) must NOT declare it so their tables reject nested paths at analysis and - * column DDL stays unsupported.

+ * {@code ColumnPath} column-DDL overrides into the connector's {@link ConnectorColumnEvolutionOps} column-evolution + * ops. Connectors without column schema-change support (JDBC, ES, paimon/maxcompute today) must NOT + * declare it so their tables reject nested paths at analysis and column DDL stays unsupported.

+ * + *

Scope: catalog-wide OR per-table. An iceberg-on-HMS table (whose catalog connector is hive) + * inherits it through the per-table set the gateway reflects from its sibling, exactly like + * {@link #SUPPORTS_NESTED_COLUMN_PRUNE}.

*/ SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumn.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumn.java index 62ba77d5120ead..5f9feffc8e38b3 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumn.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumn.java @@ -58,7 +58,7 @@ public final class ConnectorColumn { // engine consumers then ask Column.isReservedPassthrough() instead of string-matching source column names. // Defaults false; set via reservedPassthrough(). private final boolean reservedPassthrough; - // #65329 "omit-preserves-metadata" markers for MODIFY COLUMN: whether the DDL explicitly stated a + // "Omit preserves metadata" markers for MODIFY COLUMN: whether the DDL explicitly stated a // nullability / a comment (as opposed to omitting it). fe-core's ConnectorColumnConverter.toConnectorColumn // populates these from the fe-catalog Column.isNullableSpecified()/isCommentSpecified(); the iceberg nested // MODIFY path reads them so an omitted nullability never widens a field and an omitted comment keeps the @@ -144,7 +144,7 @@ public ConnectorColumn reservedPassthrough() { } /** - * Returns a copy of this column carrying the #65329 nullability/comment "specified" markers. See + * Returns a copy of this column carrying the nullability/comment "specified" markers. See * {@link #isNullableSpecified()} / {@link #isCommentSpecified()}; used by * {@code ConnectorColumnConverter.toConnectorColumn} to thread the fe-catalog Column flags across the SPI. */ @@ -214,12 +214,12 @@ public boolean isReservedPassthrough() { return reservedPassthrough; } - /** Whether the DDL explicitly stated a nullability (#65329 MODIFY COLUMN omit-preserves). */ + /** Whether the DDL explicitly stated a nullability (MODIFY COLUMN: omitting it preserves the current one). */ public boolean isNullableSpecified() { return nullableSpecified; } - /** Whether the DDL explicitly stated a comment (#65329 MODIFY COLUMN omit-preserves). */ + /** Whether the DDL explicitly stated a comment (MODIFY COLUMN: omitting it preserves the current one). */ public boolean isCommentSpecified() { return commentSpecified; } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnEvolutionOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnEvolutionOps.java new file mode 100644 index 00000000000000..7d56b586257137 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnEvolutionOps.java @@ -0,0 +1,151 @@ +// 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 org.apache.doris.connector.api.ddl.ConnectorColumnPath; +import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; + +import java.util.List; + +/** + * Column schema evolution: {@code ALTER TABLE ... ADD / DROP / RENAME / MODIFY COLUMN}, flat and nested. + * + *

The whole domain is optional — every default fails loud with a "not supported" message, which is + * the correct answer for a connector whose format cannot evolve columns.

+ * + *

Minimum implementation set:

+ *
    + *
  • Flat column evolution: implement the six top-level ops as a GROUP — + * {@link #addColumn}, {@link #addColumns}, {@link #dropColumn}, {@link #renameColumn}, + * {@link #modifyColumn}, {@link #reorderColumns}. Both connectors that support column evolution + * implement all six; a partial set means some {@code ALTER} statements succeed on a table while others + * report "not supported" on the same table.
  • + *
  • Nested column evolution: the four {@code *NestedColumn} ops plus + * {@link #modifyColumnComment}, only for a format that can evolve fields inside a struct.
  • + *
+ * + *

A heterogeneous gateway connector must route BOTH groups to its sibling. Routing only the flat six is a + * silent trap: nested {@code ALTER} statements then hit the gateway's own throwing default even though the + * sibling supports them.

+ * + *

Dotted column paths (e.g. {@code s.b}, {@code arr.element.c}, {@code m.value}) are carried neutrally by + * {@link ConnectorColumnPath}; a single-part path targets a top-level column. The fe-core bridge routes + * top-level ADD/DROP/RENAME/MODIFY through the flat ops and reserves the {@code *NestedColumn} ops for the + * nested case, except {@link #modifyColumnComment}, which is the sole entrypoint for + * {@code MODIFY COLUMN ... COMMENT} (flat and nested alike). Distinct names (rather than overloads of the flat + * {@code String} / {@link ConnectorColumn} ops) keep {@code Mockito.any()} / null call sites in connector tests + * unambiguous.

+ */ +public interface ConnectorColumnEvolutionOps { + + /** + * Adds a column to the table at the given position. + * + * @param position where to place the column ({@link ConnectorColumnPosition#FIRST} / + * {@link ConnectorColumnPosition#after(String)}); {@code null} appends at the end. + */ + @ConnectorMustImplement(when = "the connector supports column evolution") + default void addColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumn column, ConnectorColumnPosition position) { + throw new DorisConnectorException("ADD COLUMN not supported"); + } + + /** Adds multiple columns to the table, appended in order. */ + @ConnectorMustImplement(when = "the connector supports column evolution") + default void addColumns(ConnectorSession session, ConnectorTableHandle handle, + List columns) { + throw new DorisConnectorException("ADD COLUMNS not supported"); + } + + /** Drops the named column from the table. */ + @ConnectorMustImplement(when = "the connector supports column evolution") + default void dropColumn(ConnectorSession session, ConnectorTableHandle handle, + String columnName) { + throw new DorisConnectorException("DROP COLUMN not supported"); + } + + /** Renames a column. */ + @ConnectorMustImplement(when = "the connector supports column evolution") + default void renameColumn(ConnectorSession session, ConnectorTableHandle handle, + String oldName, String newName) { + throw new DorisConnectorException("RENAME COLUMN not supported"); + } + + /** + * Modifies a column's type and/or comment, optionally repositioning it. + * + * @param position where to move the column; {@code null} keeps its current position. + */ + @ConnectorMustImplement(when = "the connector supports column evolution") + default void modifyColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumn column, ConnectorColumnPosition position) { + throw new DorisConnectorException("MODIFY COLUMN not supported"); + } + + /** Reorders the table's columns to match the given full ordered list of column names. */ + @ConnectorMustImplement(when = "the connector supports column evolution") + default void reorderColumns(ConnectorSession session, ConnectorTableHandle handle, + List newOrder) { + throw new DorisConnectorException("REORDER COLUMNS not supported"); + } + + /** + * Adds a field at {@code path} (the full path of the new field: its parent struct plus the new leaf name). + * + * @param position where to place the new field within its parent struct; {@code null} appends at the end. + */ + @ConnectorMustImplement(when = "the connector supports nested column evolution") + default void addNestedColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path, ConnectorColumn column, ConnectorColumnPosition position) { + throw new DorisConnectorException("nested ADD COLUMN not supported"); + } + + /** Drops the field at {@code path}. */ + @ConnectorMustImplement(when = "the connector supports nested column evolution") + default void dropNestedColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path) { + throw new DorisConnectorException("nested DROP COLUMN not supported"); + } + + /** Renames the field at {@code path} to {@code newName} (a leaf name, not a path). */ + @ConnectorMustImplement(when = "the connector supports nested column evolution") + default void renameNestedColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path, String newName) { + throw new DorisConnectorException("nested RENAME COLUMN not supported"); + } + + /** + * Modifies the field at {@code path} (type / comment / nullability), optionally repositioning it within + * its parent struct. + * + * @param position where to move the field; {@code null} keeps its current position. + */ + @ConnectorMustImplement(when = "the connector supports nested column evolution") + default void modifyNestedColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path, ConnectorColumn column, ConnectorColumnPosition position) { + throw new DorisConnectorException("nested MODIFY COLUMN not supported"); + } + + /** Sets (or clears, with {@code ""}) the comment/doc of the field at {@code path}. */ + @ConnectorMustImplement(when = "the connector supports nested column evolution") + default void modifyColumnComment(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path, String comment) { + throw new DorisConnectorException("MODIFY COLUMN COMMENT not supported"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnStatistics.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnStatistics.java index 7c413e5d313ab8..c1f6ca91201ba5 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnStatistics.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumnStatistics.java @@ -27,15 +27,16 @@ * {@code dataSize}/{@code avgSize} — those depend on the column's fixed slot width, which the connector * cannot know without importing fe-type. fe-core derives them from {@link #getAvgSizeBytes()} (when the * source knows the average value size, e.g. a hive string column's {@code avgColLen}) or the column's slot - * width otherwise, mirroring the {@link #getTableStatistics}-style raw/derived split. Use {@link #UNKNOWN} - * when statistics are unavailable.

+ * width otherwise, mirroring the raw/derived split of + * {@link ConnectorStatisticsOps#getTableStatistics}.

+ * + *

A connector that has no per-column statistics returns {@code Optional.empty()} from + * {@link ConnectorStatisticsOps#getColumnStatistics} — that, not a sentinel instance, is how + * "unavailable" is expressed. Returning a present value with an individual field set to -1 is + * still allowed and means "this one number is unknown".

*/ public final class ConnectorColumnStatistics { - /** Sentinel indicating no per-column statistics are available. */ - public static final ConnectorColumnStatistics UNKNOWN = - new ConnectorColumnStatistics(-1, -1, -1, -1); - private final long rowCount; private final long ndv; private final long numNulls; diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java index eeacdf232b6441..36ba204b1a4e26 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.api; import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import java.util.Set; @@ -27,11 +28,22 @@ * the doc contracts the removed {@code ConnectorCapability} javadoc stated only in prose. * *

Because the invariants are static properties of a connector's own capability declarations, they are - * enforced by the per-connector contract tests (which build each connector and call {@link #validate}), + * meant to be enforced by per-connector contract tests (which build the connector and call {@link #validate}), * not at catalog registration: reading a connector's write capabilities constructs its write plan provider, * which for some connectors (e.g. iceberg) eagerly builds the live remote catalog — too costly and * outage-fragile to run on the FE metadata-replay / CREATE CATALOG path. This class stays available to any * caller that already holds an eagerly-built connector and wants the same check.

+ * + *

Actual coverage today (do not read the paragraph above as a statement that every connector is + * checked): five connectors' tests call {@link #validate} — iceberg, elasticsearch, maxcompute, jdbc and + * hive. Every invariant below now has a positive sample on a real connector: maxcompute for the local-sort + * arm, hive for the hash arm and for their mutual exclusion. A connector added without such a test is simply + * unchecked.

+ * + *

Note also that this validator reads the CONNECTOR-LEVEL provider, while the engine's write path resolves + * the provider per table ({@code Connector.getWritePlanProvider(ConnectorTableHandle)}). A heterogeneous + * gateway connector can therefore be self-consistent here and still answer differently per table, which is by + * design and out of this validator's scope.

*/ public final class ConnectorContractValidator { @@ -39,30 +51,38 @@ private ConnectorContractValidator() {} /** @throws IllegalStateException if any write-capability invariant is violated. */ public static void validate(Connector connector, String catalogType) { - Set ops = connector.supportedWriteOperations(); + // Fetch the provider ONCE. Several connectors build a fresh provider per call and one of them + // (iceberg) reaches the live remote catalog while doing so, so asking the connector separately for + // each trait would pay that cost eight times over. + ConnectorWritePlanProvider provider = connector.getWritePlanProvider(); + if (provider == null) { + // No write support at all: every trait is vacuously false and no invariant can be violated. + return; + } + Set ops = provider.supportedOperations(); // #2 branch-write implies plain INSERT is supported (branch is an INSERT modifier). - if (connector.supportsWriteBranch() && !ops.contains(WriteOperation.INSERT)) { + if (provider.supportsWriteBranch() && !ops.contains(WriteOperation.INSERT)) { throw new IllegalStateException("Connector '" + catalogType + "' declares supportsWriteBranch but its supportedOperations lacks INSERT"); } // #3 partition-local-sort implies parallel write AND full-schema write order. - if (connector.requiresPartitionLocalSort() - && !(connector.requiresParallelWrite() && connector.requiresFullSchemaWriteOrder())) { + if (provider.requiresPartitionLocalSort() + && !(provider.requiresParallelWrite() && provider.requiresFullSchemaWriteOrder())) { throw new IllegalStateException("Connector '" + catalogType + "' declares requiresPartitionLocalSort without requiresParallelWrite" + " AND requiresFullSchemaWriteOrder"); } // #4 partition-hash-write (hash without sort) likewise implies parallel write AND full-schema write // order (the sink indexes partition columns by full-schema position and distributes in parallel). - if (connector.requiresPartitionHashWrite() - && !(connector.requiresParallelWrite() && connector.requiresFullSchemaWriteOrder())) { + if (provider.requiresPartitionHashWrite() + && !(provider.requiresParallelWrite() && provider.requiresFullSchemaWriteOrder())) { throw new IllegalStateException("Connector '" + catalogType + "' declares requiresPartitionHashWrite without requiresParallelWrite" + " AND requiresFullSchemaWriteOrder"); } // #5 the two hash arms are mutually exclusive: the engine checks local-sort first, so declaring both // would silently ignore the hash-without-sort request. Fail loud instead. - if (connector.requiresPartitionLocalSort() && connector.requiresPartitionHashWrite()) { + if (provider.requiresPartitionLocalSort() && provider.requiresPartitionHashWrite()) { throw new IllegalStateException("Connector '" + catalogType + "' declares both requiresPartitionLocalSort and requiresPartitionHashWrite;" + " a connector must pick at most one partition-distribution arm"); diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java index 6d386f1f77b24e..dda216dbca0d97 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java @@ -26,9 +26,7 @@ import java.io.Closeable; import java.io.IOException; -import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.OptionalLong; import java.util.Set; @@ -39,7 +37,15 @@ *

Extends the fine-grained sub-interfaces for schema, table, * pushdown, statistics, and write operations. Each sub-interface * provides sensible defaults so that connectors only need to - * override the methods they actually support.

+ * override the methods they actually support. Because every method has a default, the compiler forces NO + * method on an implementor: an empty implementation compiles and loads but serves nothing, so each + * sub-interface documents its own minimum implementation set.

+ * + *

Lifecycle: exactly one instance per catalog per statement, obtained by the engine through its + * single entry point ({@code PluginDrivenMetadata}, which owns this contract) and closed deterministically + * when the statement ends. One instance must not be shared across threads. Note that + * {@link ConnectorStatisticsOps} — inherited here — is exempt from the query-thread assumption; see its class + * javadoc.

*/ public interface ConnectorMetadata extends ConnectorSchemaOps, @@ -50,11 +56,6 @@ public interface ConnectorMetadata extends ConnectorIdentifierOps, Closeable { - /** Returns connector-level properties. */ - default Map getProperties() { - return Collections.emptyMap(); - } - // ──────────────────── MVCC Snapshots ──────────────────── /** @@ -175,8 +176,13 @@ default ConnectorTableHandle applySnapshot(ConnectorSession session, * over the scan, binding column references to the connector's own (visible) output columns by name. * *

The predicate is expressed in the connector-neutral {@link ConnectorExpression} pushdown grammar, - * NOT a source-specific shape — the engine NEVER discriminates by connector here; it applies whatever the - * connector returns. This mirrors the engine-agnostic residual-predicate model.

+ * NOT a source-specific shape — the engine NEVER discriminates by connector here. It does, however, accept + * only a narrow subset of that grammar on this path, because it has to translate the expression back into + * an engine predicate: conjunction (AND), the five comparisons EQ / LT / LE / GT / GE, a column reference + * that binds BY NAME to one of the scan's visible output columns, and STRING literals. Anything else + * (OR, NOT, IN, LIKE, arithmetic, a non-STRING literal, an unbindable column) makes the reverse conversion + * throw and fails the query — deliberately loud, so a silently dropped correctness filter is impossible. + * Keep what you return inside that subset.

* *

The default returns an EMPTY list: a connector with no synthetic scan predicate adds nothing, so the * plan is byte-identical. iceberg/paimon/jdbc/... inherit this empty default; only a connector that opts in @@ -229,6 +235,11 @@ default ConnectorTableHandle applyTopnLazyMaterialization(ConnectorSession sessi } + /** + * Releases whatever this per-statement instance holds. Called by the engine exactly once per statement in + * the normal path, but implementations must be idempotent — a failed statement can reach close from + * more than one unwind path. Must not throw for an already-closed instance. + */ @Override default void close() throws IOException { } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMustImplement.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMustImplement.java new file mode 100644 index 00000000000000..492da94fdbe6b7 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMustImplement.java @@ -0,0 +1,50 @@ +// 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.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a default SPI method that a connector is nevertheless expected to override. + * + *

Every method of {@link ConnectorMetadata} and its sub-interfaces has a default body, so the compiler + * forces nothing on an implementor: an empty implementation compiles and loads, and then answers "no tables" + * / "table not found" to users instead of failing. This annotation is the machine-readable half of the fix — + * it says WHICH methods make up the minimum implementation set; the interface's class javadoc says WHY, and + * what the visible symptom of skipping one is.

+ * + *

Nothing in production reads this annotation: there is no registration-time validation and no build gate + * (a check that has to understand which capability a connector declared cannot be written as a text match). + * The retention is {@code RUNTIME} only so that a unit test can reflect over it and pin the set, which makes + * promoting a method to "must implement" a deliberate, reviewed change rather than a silent one.

+ */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ConnectorMustImplement { + + /** + * The precondition that makes this method mandatory. Empty means unconditional — every connector must + * override it. Otherwise a short phrase naming the capability or situation that triggers the obligation. + */ + String when() default ""; +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java index 2eb569d2642b3d..a433626757124a 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java @@ -54,9 +54,13 @@ public final class ConnectorPartitionInfo { * {@link #partitionValueNullFlags} — i.e. value {@code i} is the value of the {@code i}-th * {@code key=value} segment of {@link #partitionName}, decoded exactly as fe-core's legacy name parse * would produce it (so the connector supplies what fe-core used to re-parse out of the name). - * Empty means "not supplied": fe-core then falls back to parsing {@link #partitionName} itself - * (unchanged behavior). A connector that lists partitions for the MVCC partition-item path - * (hive/paimon/iceberg/hudi) supplies this so fe-core does not re-run the hive-style parse. + * + *

A connector that lists partitions for the MVCC partition-item path (hive/paimon/iceberg/hudi) + * MUST supply this. There is no name-parsing fallback in fe-core any more. fe-core checks that the + * number of values matches the number of partition columns and fails that partition when it does not; + * the failure is caught per partition, so the visible effect of leaving this empty is not an error but a + * silent degradation: every partition is skipped, the table is treated as unpartitioned, and partition + * pruning is lost (only a warn line in the FE log records it).

*/ private final List orderedPartitionValues; @@ -163,7 +167,18 @@ public long getSizeBytes() { return sizeBytes; } - /** @return last-modified epoch millis, or {@link #UNKNOWN}. */ + /** + * @return last-modified epoch millis, or {@link #UNKNOWN}. + * + *

The unit is load-bearing, not decorative. The engine subtracts this from the + * wall clock to decide whether the table has been quiet long enough to serve a query from + * the SQL cache. Because it clamps "now" to at least this value (a guard against FE/metadata + * clock skew), a value from any other scale - a source-native version, a commit id, a + * timestamp in another unit - makes that difference come out as zero forever and SILENTLY + * disables the SQL cache for this table AND for every table queried alongside it. There is + * no error and no EXPLAIN signal; the only symptom is that the cache never engages. Report + * {@link #UNKNOWN} rather than a value in a different unit. + */ public long getLastModifiedMillis() { return lastModifiedMillis; } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionListingOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionListingOps.java new file mode 100644 index 00000000000000..bf31c7468d0597 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionListingOps.java @@ -0,0 +1,64 @@ +// 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 org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Enumerating a table's partitions. + * + *

Minimum implementation set: a connector whose tables can be partitioned implements + * {@link #listPartitionNames} and {@link #listPartitions}. A connector without partitioned tables implements + * nothing here and the empty defaults are correct for it.

+ * + *

When supplying {@link ConnectorPartitionInfo} objects, note that the ordered partition values are + * mandatory on the MVCC partition-item path — see that class for what silently happens when they are + * omitted.

+ */ +public interface ConnectorPartitionListingOps { + + /** + * Lists all partition display names (e.g., {@code "year=2024/month=01"}). + * + *

Should be cheap and avoid loading per-partition metadata.

+ */ + @ConnectorMustImplement(when = "the connector has partitioned tables") + default List listPartitionNames(ConnectorSession session, + ConnectorTableHandle handle) { + return Collections.emptyList(); + } + + /** + * Lists partitions matching the optional filter, with full metadata. + * + *

Connectors should push the filter into the metastore / catalog when + * possible. {@code filter} is empty when the caller wants the full list.

+ */ + @ConnectorMustImplement(when = "the connector has partitioned tables") + default List listPartitions(ConnectorSession session, + ConnectorTableHandle handle, + Optional filter) { + return Collections.emptyList(); + } + +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPassthroughSqlOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPassthroughSqlOps.java new file mode 100644 index 00000000000000..589377f698de73 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPassthroughSqlOps.java @@ -0,0 +1,56 @@ +// 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; + +/** + * Passing a SQL string through to the remote source untouched, for a connector that fronts a system which + * speaks SQL itself. + * + *

Optional: implement this interface, or do not. It is not part of {@link ConnectorMetadata}, so a + * connector that has no remote SQL dialect never sees these methods and owes them nothing. Implementing the + * interface IS the declaration — there is no accompanying capability flag to keep in step with it (a + * {@code SUPPORTS_PASSTHROUGH_QUERY} flag existed and was removed: it was a second overridable answer to the + * question "can this connector run my SQL", and a connector could declare it while implementing nothing).

+ * + *

Both methods take a SQL string the user wrote. A connector that implements them owns the consequences: + * the engine does not parse, rewrite or authorize the statement beyond the catalog-level privilege check on + * the entry points, so an implementation must send it under the catalog's own credentials and must not widen + * what those credentials can reach.

+ * + *

Minimum implementation set: whichever of the two the connector actually supports. Each defaults to + * refusing, so implementing the interface for the {@code query()} TVF alone does not silently claim + * {@code CALL EXECUTE_STMT} as well.

+ */ +public interface ConnectorPassthroughSqlOps { + + /** + * Executes a DML statement (INSERT/UPDATE/DELETE) on the remote source verbatim, for + * {@code CALL EXECUTE_STMT(catalog, stmt)}. Nothing is returned: the statement's effect is remote. + */ + default void executeStmt(ConnectorSession session, String stmt) { + throw new DorisConnectorException("executeStmt not supported"); + } + + /** + * Returns the column metadata of an arbitrary remote query, for the {@code query()} table-valued function + * (typically via the remote driver's prepared-statement metadata, without running the query). + */ + default ConnectorTableSchema getColumnsFromQuery(ConnectorSession session, String query) { + throw new DorisConnectorException("getColumnsFromQuery not supported"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPropertyMetadata.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPropertyMetadata.java deleted file mode 100644 index f53e2d916c6095..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPropertyMetadata.java +++ /dev/null @@ -1,120 +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.api; - -import java.util.Objects; - -/** - * Describes a configuration property that a connector exposes. - * - * @param the Java type of the property value - */ -public final class ConnectorPropertyMetadata { - - private final String name; - private final String description; - private final Class type; - private final T defaultValue; - private final boolean required; - - private ConnectorPropertyMetadata(String name, String description, - Class type, T defaultValue, boolean required) { - this.name = Objects.requireNonNull(name, "name"); - this.description = Objects.requireNonNull( - description, "description"); - this.type = Objects.requireNonNull(type, "type"); - this.defaultValue = defaultValue; - this.required = required; - } - - /** Creates an optional String property with a default value. */ - public static ConnectorPropertyMetadata stringProperty( - String name, String description, String defaultValue) { - return new ConnectorPropertyMetadata<>( - name, description, String.class, defaultValue, false); - } - - /** Creates an optional int property with a default value. */ - public static ConnectorPropertyMetadata intProperty( - String name, String description, int defaultValue) { - return new ConnectorPropertyMetadata<>( - name, description, Integer.class, defaultValue, false); - } - - /** Creates an optional boolean property with a default value. */ - public static ConnectorPropertyMetadata booleanProperty( - String name, String description, boolean defaultValue) { - return new ConnectorPropertyMetadata<>( - name, description, Boolean.class, defaultValue, false); - } - - /** Creates a required String property with no default. */ - public static ConnectorPropertyMetadata requiredStringProperty( - String name, String description) { - return new ConnectorPropertyMetadata<>( - name, description, String.class, null, true); - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public Class getType() { - return type; - } - - public T getDefaultValue() { - return defaultValue; - } - - public boolean isRequired() { - return required; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ConnectorPropertyMetadata)) { - return false; - } - ConnectorPropertyMetadata that = (ConnectorPropertyMetadata) o; - return required == that.required - && name.equals(that.name) - && description.equals(that.description) - && type.equals(that.type) - && Objects.equals(defaultValue, that.defaultValue); - } - - @Override - public int hashCode() { - return Objects.hash(name, description, type, defaultValue, required); - } - - @Override - public String toString() { - return "ConnectorPropertyMetadata{name='" + name - + "', type=" + type.getSimpleName() - + ", required=" + required + "}"; - } -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPushdownOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPushdownOps.java index e29bd54d5c5b61..7665440494968d 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPushdownOps.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPushdownOps.java @@ -21,7 +21,6 @@ import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.pushdown.ConnectorFilterConstraint; import org.apache.doris.connector.api.pushdown.FilterApplicationResult; -import org.apache.doris.connector.api.pushdown.LimitApplicationResult; import org.apache.doris.connector.api.pushdown.ProjectionApplicationResult; import java.util.List; @@ -50,24 +49,36 @@ public interface ConnectorPushdownOps { return Optional.empty(); } - /** Attempts to push a limit into the table scan. */ - default Optional> - applyLimit(ConnectorSession session, - ConnectorTableHandle handle, long limit) { - return Optional.empty(); - } - /** * Returns whether this connector supports pushing down predicates that contain * implicit CAST expressions. * - *

When this returns {@code false}, the engine will strip any conjuncts - * containing CAST expressions from the filter before passing it to the connector. - * The default is {@code true} (CASTs are pushed down).

+ *

This switch governs ONE of the two pushdown paths. Returning {@code false} makes the engine + * drop CAST-containing conjuncts from the RESIDUAL predicate it builds for the scan node. The + * {@link #applyFilter} path never consults it: there the engine converts every conjunct and hands the + * result to the connector regardless of what this method answers.

+ * + *

And on either path a CAST does not arrive as a CAST. The forward converter unwraps it and + * pushes the child expression, so {@code CAST(dt AS INT) = 20240101} reaches the connector as + * {@code dt = 20240101}, with nothing marking it as coerced — a connector cannot detect the situation by + * inspecting what it received. The risk is therefore carried by the connector: a connector that turns such + * a comparison into remote filtering (e.g. metastore partition pruning from equality / IN predicates on + * partition columns) will UNDER-return rows whenever the remote comparison semantics differ from Doris's + * coercion, and BE cannot recover the difference because the data was never scanned.

+ * + *

The default is {@code true} (CASTs are pushed down), which is an opt-OUT polarity and the one + * exception to this module's "capabilities default to false" rule; it is kept for compatibility. Do not + * copy the polarity into a new switch. A connector that delegates filtering to a remote system with + * different type coercion rules (e.g. a JDBC database) overrides this to {@code false}, optionally driven + * by session configuration.

* - *

Connectors that delegate filtering to remote systems with different type - * coercion rules (e.g., JDBC databases) may override this to disable CAST - * pushdown when the session configuration indicates it is unsafe.

+ *

Every shipped connector answers this deliberately; none of them merely inherits. jdbc, paimon + * and maxcompute return {@code false}; iceberg, elasticsearch and the trino bridge state {@code true} at + * their own metadata class, each recording what it does with the predicate and that {@code true} is an + * accepted risk rather than a safety claim. hive and hudi declare nothing because for them the switch is + * INERT — their scan planning ignores the residual filter entirely, and the predicate they do consume + * arrives on the {@link #applyFilter} path, which is never gated on this method. A new connector should + * make the same deliberate choice, starting from {@code false}.

*/ default boolean supportsCastPredicatePushdown(ConnectorSession session) { return true; diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSchemaOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSchemaOps.java index fe7c6a485128ce..6436bed1a7a04f 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSchemaOps.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSchemaOps.java @@ -50,37 +50,34 @@ default ConnectorDatabaseMetadata getDatabase( } /** - * Whether this connector supports CREATE DATABASE. Defaults to false so the FE - * {@code CREATE DATABASE IF NOT EXISTS} remote existence precheck applies only to - * connectors that can actually create databases; connectors that cannot keep their - * existing "CREATE DATABASE not supported" behavior unchanged. + * Creates a new database with the given name and properties. The default throws, which IS the + * declaration that this connector cannot create databases — there is no separate boolean to keep in + * sync with it (a {@code supportsCreateDatabase()} switch existed and was removed: it could only + * restate whether this method was overridden, and getting the two out of step broke + * {@code CREATE DATABASE IF NOT EXISTS} with no compile error and no failing test). + * + *

{@code IF NOT EXISTS} is handled by the engine, not here: it consults {@link #databaseExists} + * first and only calls this when the answer is no. A connector that cannot create databases therefore + * still wants a truthful {@link #databaseExists} — that is what makes {@code IF NOT EXISTS} on an + * already-existing database succeed rather than report "not supported".

*/ - default boolean supportsCreateDatabase() { - return false; - } - - /** Creates a new database with the given name and properties. */ default void createDatabase(ConnectorSession session, String dbName, Map properties) { throw new DorisConnectorException( "CREATE DATABASE not supported"); } - /** Drops the specified database. */ - default void dropDatabase(ConnectorSession session, - String dbName, boolean ifExists) { - throw new DorisConnectorException( - "DROP DATABASE not supported"); - } - /** - * Drops the specified database, cascading to its tables when {@code force} is - * true. The default delegates to the non-cascading 3-arg form, so connectors - * that do not support cascade keep their current behavior with zero change; - * a connector that supports FORCE overrides this overload. + * Drops the specified database, cascading to its tables when {@code force} is true. + * + *

This is the only drop-database entry point: a 3-arg form without {@code force} used to exist and + * this overload defaulted to it, so {@code DROP DATABASE ... FORCE} silently became a non-cascading drop + * that then failed on a non-empty database. A connector that cannot cascade should reject {@code force} + * explicitly instead.

*/ default void dropDatabase(ConnectorSession session, String dbName, boolean ifExists, boolean force) { - dropDatabase(session, dbName, ifExists); + throw new DorisConnectorException( + "DROP DATABASE not supported"); } } 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 320805bacd0faf..e4f5fdd833efaf 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 @@ -71,7 +71,20 @@ default Optional getDelegatedCredential() { /** Returns the catalog name this session is bound to. */ String getCatalogName(); - /** Retrieves a typed session/catalog property. */ + /** + * Retrieves a typed property by name, searching the SESSION variables first and the CATALOG properties + * second; returns {@code null} when neither namespace has it. + * + *

The two namespaces are merged silently. A name present in both resolves to the session value + * and the caller cannot tell which side answered — convenient for a knob that a catalog sets as a default + * and a user overrides per query, wrong for anything where the origin matters (a catalog-level credential + * or endpoint must not be overridable by a session variable of the same name). When the origin matters, + * read the namespace directly: {@link #getSessionProperties()} or {@link #getCatalogProperties()}.

+ * + * @param name the property name, as spelled in the FE session-variable registry or in the catalog properties + * @param type one of {@code String}, {@code Integer}, {@code Long} or {@code Boolean} (any other type is + * rejected); the stored string is parsed into it + */ T getProperty(String name, Class type); /** Returns all catalog-level configuration properties. */ diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSnapshotRefOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSnapshotRefOps.java new file mode 100644 index 00000000000000..e1f4dee764bcc1 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSnapshotRefOps.java @@ -0,0 +1,88 @@ +// 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 org.apache.doris.connector.api.ddl.BranchChange; +import org.apache.doris.connector.api.ddl.DropRefChange; +import org.apache.doris.connector.api.ddl.PartitionFieldChange; +import org.apache.doris.connector.api.ddl.TagChange; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; + +/** + * Snapshot references (branches and tags) and partition-spec evolution. + * + *

The whole domain is optional, and only meaningful for a format that has a snapshot log with named + * references and a mutable partition spec. Every default fails loud with a "not supported" message.

+ * + *

Minimum implementation set: the two groups are independent, but each is all-or-nothing. Implement all + * four of {@link #createOrReplaceBranch} / {@link #createOrReplaceTag} / {@link #dropBranch} / + * {@link #dropTag} together — a half set lets a user create a branch that they then cannot drop. Likewise + * implement {@link #addPartitionField} / {@link #dropPartitionField} / {@link #replacePartitionField} + * together.

+ */ +public interface ConnectorSnapshotRefOps { + + /** Creates or replaces a named branch (snapshot ref) on the table. */ + @ConnectorMustImplement(when = "the connector exposes branches and tags") + default void createOrReplaceBranch(ConnectorSession session, ConnectorTableHandle handle, + BranchChange branch) { + throw new DorisConnectorException("CREATE/REPLACE BRANCH not supported"); + } + + /** Creates or replaces a named tag (snapshot ref) on the table. */ + @ConnectorMustImplement(when = "the connector exposes branches and tags") + default void createOrReplaceTag(ConnectorSession session, ConnectorTableHandle handle, + TagChange tag) { + throw new DorisConnectorException("CREATE/REPLACE TAG not supported"); + } + + /** Drops a named branch (snapshot ref) from the table. */ + @ConnectorMustImplement(when = "the connector exposes branches and tags") + default void dropBranch(ConnectorSession session, ConnectorTableHandle handle, + DropRefChange branch) { + throw new DorisConnectorException("DROP BRANCH not supported"); + } + + /** Drops a named tag (snapshot ref) from the table. */ + @ConnectorMustImplement(when = "the connector exposes branches and tags") + default void dropTag(ConnectorSession session, ConnectorTableHandle handle, + DropRefChange tag) { + throw new DorisConnectorException("DROP TAG not supported"); + } + + /** Adds a partition field (column reference + optional transform) to the table's partition spec. */ + @ConnectorMustImplement(when = "the connector supports partition-spec evolution") + default void addPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + throw new DorisConnectorException("ADD PARTITION FIELD not supported"); + } + + /** Drops a partition field from the table's partition spec. */ + @ConnectorMustImplement(when = "the connector supports partition-spec evolution") + default void dropPartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + throw new DorisConnectorException("DROP PARTITION FIELD not supported"); + } + + /** Replaces a partition field (removes the old field, adds the new one) in the table's partition spec. */ + @ConnectorMustImplement(when = "the connector supports partition-spec evolution") + default void replacePartitionField(ConnectorSession session, ConnectorTableHandle handle, + PartitionFieldChange change) { + throw new DorisConnectorException("REPLACE PARTITION FIELD not supported"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java index 55fe9dd731b91b..700868d7832dc5 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java @@ -26,6 +26,20 @@ /** * Operations for retrieving table-level statistics from a connector. + * + *

These methods may run OFF the query thread, on engine background pools, and the engine pins no + * thread context classloader on any of them. The pools reached today are the column-statistics cache + * loader ({@code STATS_FETCH}), the analysis-job executor that runs {@code ANALYZE ... WITH SAMPLE}, and the + * external row-count refresh executor. (The snapshot-aware {@code getTableStatistics} overload is invoked on + * the query thread only, but do not rely on that: treat every method here as background-callable.)

+ * + *

Consequence for a connector: if an implementation — or a bundled library it calls — loads classes + * reflectively BY NAME, it must pin the thread context classloader to its own plugin classloader for the + * duration of the call and restore it afterwards. Without that pin the name resolves against the engine's own + * copy of the class on a background thread, which surfaces as an intermittent + * {@code ClassCastException} / {@code NoClassDefFoundError} that only appears while statistics are being + * collected. The hive connector's {@code listFileSizes} implementation does this pinning itself and is the + * pattern to copy; the engine's side of the boundary states explicitly that it does not pin here.

*/ public interface ConnectorStatisticsOps { @@ -90,8 +104,10 @@ default long estimateDataSizeByListingFiles(ConnectorSession session, ConnectorT * factor, then does the Doris-type slot-width math itself. Unlike {@link #estimateDataSizeByListingFiles} it * neither partition-samples nor sums, because the sampler needs the individual file sizes. A potentially * expensive full remote listing, so connectors that cannot enumerate files cheaply must NOT override it - * (default empty -> the sampler falls back to scale factor 1). Best-effort: an override must return empty on - * any listing error rather than throw (statistics must not fail a query). + * (default empty -> the sampler falls back to scale factor 1). An override must let a listing error + * propagate rather than swallow it: an empty list is indistinguishable from "this table has no files", so + * swallowing turns an unreachable metastore into a silently wrong scale factor of 1 while the sample still + * runs. Propagating fails only the ANALYZE task that asked, never an unrelated query. */ default List listFileSizes(ConnectorSession session, ConnectorTableHandle handle) { return Collections.emptyList(); diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableDdlOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableDdlOps.java new file mode 100644 index 00000000000000..ad094e23efabea --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableDdlOps.java @@ -0,0 +1,85 @@ +// 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 org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; + +import java.util.List; + +/** + * Table-level DDL: create, drop, rename, truncate. + * + *

The whole domain is optional — a read-only connector implements nothing here and every default + * fails loud with a "not supported" message, which is the correct answer for it.

+ * + *

Minimum implementation set: to support {@code CREATE TABLE}, override + * {@link #createTable(ConnectorSession, ConnectorCreateTableRequest)}. There is deliberately only ONE create + * entry point: a narrower {@code (schema, properties)} overload used to exist and the request overload + * degraded to it, silently dropping the partition spec, the bucket spec and {@code IF NOT EXISTS} — a + * connector implementing only the narrow form reported success on a partitioned {@code CREATE TABLE} and + * created an unpartitioned table. Nothing implemented it, so it is gone rather than documented. + * {@link #dropTable}, {@link #renameTable} and {@link #truncateTable} are independent and optional.

+ */ +public interface ConnectorTableDdlOps { + + /** + * Creates a table with full DDL semantics (partition, bucket, {@code IF NOT EXISTS}). + * + *

The request carries everything the statement said; a connector that cannot honor part of it must + * reject that part rather than ignore it, because a {@code CREATE TABLE} reporting success after dropping + * the partition spec is indistinguishable from one that worked.

+ * + * @throws DorisConnectorException if the connector cannot create tables, or cannot honor the request + */ + @ConnectorMustImplement(when = "the connector supports CREATE TABLE") + default void createTable(ConnectorSession session, + ConnectorCreateTableRequest request) { + throw new DorisConnectorException( + "CREATE TABLE not supported"); + } + + /** Drops the specified table. */ + default void dropTable(ConnectorSession session, + ConnectorTableHandle handle) { + throw new DorisConnectorException( + "DROP TABLE not supported"); + } + + /** Renames the table identified by {@code handle} to {@code newName} within the same database. */ + default void renameTable(ConnectorSession session, + ConnectorTableHandle handle, String newName) { + throw new DorisConnectorException( + "RENAME TABLE not supported"); + } + + /** + * Truncates the table identified by {@code handle}. When {@code partitions} is non-empty only those + * partitions are truncated; {@code null} / empty truncates the whole table. + * + *

Connectors that support {@code TRUNCATE TABLE} override this. The default throws, matching the + * pre-flip behavior of the generic bridge (which had no truncate route for the SPI path).

+ * + * @throws DorisConnectorException if the connector does not support truncate + */ + default void truncateTable(ConnectorSession session, + ConnectorTableHandle handle, List partitions) { + throw new DorisConnectorException( + "TRUNCATE TABLE not supported"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableMetadataOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableMetadataOps.java new file mode 100644 index 00000000000000..ef866dfd5f2602 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableMetadataOps.java @@ -0,0 +1,248 @@ +// 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 org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Resolving a table by name, and reading what it looks like. + * + *

This is the one domain no connector can skip. The four methods marked unconditional below are + * overridden by all eight shipped connectors, and {@link #buildTableDescriptor} by seven of them. Leaving them + * at their defaults is not an error the user sees as an error: {@link #listTableNames} answers an empty list, + * so {@code SHOW TABLES} looks like an empty database, and {@link #getTableHandle} answers empty, so a query + * looks like a mistyped table name. Only {@link #getTableSchema} and {@link #getColumnHandles} fail loud.

+ * + *

Minimum implementation set:

+ *
    + *
  • Always: {@link #getTableHandle}, {@link #listTableNames}, + * {@link #getTableSchema(ConnectorSession, ConnectorTableHandle)}, + * {@link #getColumnHandles(ConnectorSession, ConnectorTableHandle)}, {@link #buildTableDescriptor}. The + * descriptor may be left to the engine's generic fallback (returning {@code null}) if the connector needs + * no typed descriptor for BE — one shipped connector relies on that — but decide it deliberately.
  • + *
  • Time travel / schema evolution: the snapshot-aware + * {@link #getTableSchema(ConnectorSession, ConnectorTableHandle, ConnectorMvccSnapshot)}. Implementing + * only this one is the common case (three of the four snapshot-capable connectors stop here). + * {@link #getColumnHandles(ConnectorSession, ConnectorTableHandle, ConnectorMvccSnapshot)} is a separate, + * stronger step: implement it only if handles are keyed by the PINNED schema's names, and then also + * declare {@link #supportsColumnHandleSnapshotPin} so the engine turns on its fail-loud check. A connector + * that recovers from a pinned-name miss by other means (rebuilding a field-id dictionary, for example) + * legitimately leaves both alone.
  • + *
  • System tables: {@link #listSupportedSysTables} plus {@link #getSysTableHandle}; + * {@link #isPartitionValuesSysTable} only for a system table served by the engine's generic + * partition-values function rather than by a native scan.
  • + *
  • Optional: {@link #getTableComment}, {@link #renderShowCreateTableDdl}.
  • + *
+ * + *

Note that {@link #getTableComment} addresses a table by NAME, not by handle. A heterogeneous gateway + * connector routes foreign tables to a sibling by the concrete handle type, so it cannot route a name-only + * method that way; if you are writing a gateway, handle it explicitly.

+ */ +public interface ConnectorTableMetadataOps { + + /** Retrieves a table handle for the given database and table name. */ + @ConnectorMustImplement + default Optional getTableHandle( + ConnectorSession session, String dbName, + String tableName) { + return Optional.empty(); + } + + /** + * Lists the system-table names supported for the given base table + * (e.g. ["snapshots", "schemas", "options", "audit_log", "binlog"]). + * + *

The names are WITHOUT any "$" prefix; fe-core composes the + * "{baseTable}${sysName}" reference name. Default: empty (no system + * tables). Implemented by connectors that expose system tables.

+ */ + @ConnectorMustImplement(when = "the connector exposes system tables") + default List listSupportedSysTables(ConnectorSession session, + ConnectorTableHandle baseTableHandle) { + return Collections.emptyList(); + } + + /** + * Returns a handle for the named system table of the given base table, + * or empty if this connector does not expose that system table. + * + *

The returned handle is connector-internal and carries whatever the + * connector needs (system-table name, scan-routing hints, etc.); it is + * opaque to fe-core. {@code sysName} is the bare name (no "$").

+ */ + @ConnectorMustImplement(when = "the connector exposes system tables") + default Optional getSysTableHandle(ConnectorSession session, + ConnectorTableHandle baseTableHandle, String sysName) { + return Optional.empty(); + } + + /** + * Whether the named system table of {@code baseTableHandle} is served by the generic + * {@code partition_values} table-valued function (fe-core's {@code PartitionsSysTable}) rather + * than by a native connector scan. Default {@code false} (native, the {@link #getSysTableHandle} + * path). + * + *

A connector whose partitioned tables expose their partition rows through the generic + * partition-values TVF (e.g. hive) overrides this to return {@code true} for that sys-table name; + * such a name need NOT return a handle from {@link #getSysTableHandle} (the TVF path never consults + * it). fe-core needs the kind at discovery time (before any handle is fetched), so it cannot be + * inferred from an empty {@code getSysTableHandle}. {@code sysName} is the bare name (no + * {@code "$"}).

+ */ + @ConnectorMustImplement(when = "a system table is served by the generic partition-values function") + default boolean isPartitionValuesSysTable(ConnectorSession session, + ConnectorTableHandle baseTableHandle, String sysName) { + return false; + } + + /** Returns the schema (columns, format, etc.) for the given table. */ + @ConnectorMustImplement + default ConnectorTableSchema getTableSchema( + ConnectorSession session, ConnectorTableHandle handle) { + throw new DorisConnectorException( + "getTableSchema not implemented"); + } + + /** + * Returns the schema AT {@code snapshot.getSchemaId()} — the schema as of the + * pinned snapshot, for time-travel reads under schema evolution. + * + *

The default ignores the snapshot and returns the latest schema via + * {@link #getTableSchema(ConnectorSession, ConnectorTableHandle)}. A connector that + * supports schema-at-snapshot overrides this to resolve the schema version.

+ */ + @ConnectorMustImplement(when = "the connector supports time travel or schema evolution") + default ConnectorTableSchema getTableSchema( + ConnectorSession session, ConnectorTableHandle handle, + ConnectorMvccSnapshot snapshot) { + return getTableSchema(session, handle); + } + + /** + * Renders the native {@code SHOW CREATE TABLE} DDL for a table, fetching schema FRESH from the underlying + * metastore at call time (bypassing any connector-side table cache) so the returned statement always + * reflects the latest remote schema. + * + *

This is a LAZY, per-call interception point used ONLY by {@code ShowCreateTableCommand}. It intentionally + * does NOT participate in the {@code SUPPORTS_SHOW_CREATE_DDL} capability (which gates the engine-assembled + * DDL in {@code Env.getDdlStmt} for every caller, including delegated sibling tables and the HTTP schema + * endpoint). A connector that does not natively render its own SHOW CREATE returns {@link Optional#empty()}, + * and the command falls through to the generic {@code Env.getDdlStmt} path unchanged.

+ * + * @return the full {@code CREATE TABLE} statement, or {@link Optional#empty()} to defer to the engine + */ + default Optional renderShowCreateTableDdl( + ConnectorSession session, ConnectorTableHandle handle) { + return Optional.empty(); + } + + /** Returns a name-to-handle map for all columns of the table. */ + @ConnectorMustImplement + default Map getColumnHandles( + ConnectorSession session, ConnectorTableHandle handle) { + throw new DorisConnectorException( + "getColumnHandles not implemented"); + } + + /** + * Returns a name-to-handle map for all columns AT {@code snapshot.getSchemaId()} — the + * columns as of the pinned snapshot, for time-travel reads under schema evolution. + * + *

The default ignores the snapshot and returns the latest columns via + * {@link #getColumnHandles(ConnectorSession, ConnectorTableHandle)}. WHY this exists: the generic + * scan node builds column handles BEFORE it pins the snapshot, so without a snapshot-aware overload + * it can only key handles by the LATEST names. A time-travel query binds its slots to the PINNED + * (old) names, so after a RENAME the renamed column's slot misses the latest-keyed map and is + * silently dropped — crashing BE with a field-id dictionary miss on connectors whose native + * projection is name/ordinal-driven (paimon). A connector that supports schema-at-snapshot + * overrides this to key handles by the pinned names (mirrors the + * {@link #getTableSchema(ConnectorSession, ConnectorTableHandle, ConnectorMvccSnapshot)} split) and + * declares {@link #supportsColumnHandleSnapshotPin(ConnectorSession)}.

+ */ + @ConnectorMustImplement(when = "column handles must be keyed by the pinned schema's names") + default Map getColumnHandles( + ConnectorSession session, ConnectorTableHandle handle, + ConnectorMvccSnapshot snapshot) { + return getColumnHandles(session, handle); + } + + /** + * Whether {@link #getColumnHandles(ConnectorSession, ConnectorTableHandle, ConnectorMvccSnapshot)} + * resolves handles AT the pinned snapshot's schema (i.e. keys them by the pinned names). + * + *

Only a connector that returns {@code true} is subject to the generic node's fail-loud check + * that every bound column present in the pinned schema has a handle: for such a connector a missing + * pinned column is a genuine bug (the connector promised pinned handles but dropped one) and must + * fail with a clear error rather than being silently dropped into a BE crash. A connector that + * returns {@code false} keeps the legacy latest-keyed handles and recovers from the drop by its own + * means (e.g. iceberg rebuilds its field-id dictionary from the full pinned schema), so it is left + * on the unchanged silent-skip path.

+ */ + @ConnectorMustImplement(when = "column handles are keyed by the pinned schema's names") + default boolean supportsColumnHandleSnapshotPin(ConnectorSession session) { + return false; + } + + /** Lists all table names within the given database. */ + @ConnectorMustImplement + default List listTableNames(ConnectorSession session, + String dbName) { + return Collections.emptyList(); + } + + /** Returns a human-readable comment for the given table. */ + default String getTableComment(ConnectorSession session, + String dbName, String tableName) { + return ""; + } + + /** + * Builds the Thrift {@code TTableDescriptor} that BE needs for query execution. + * + *

Each connector constructs its own typed descriptor (e.g., {@code TJdbcTable}, + * {@code TEsTable}) and wraps it in a {@code TTableDescriptor}. This keeps + * connector-specific Thrift logic out of fe-core.

+ * + *

The Thrift classes are provided by fe-thrift at compile time and loaded + * from the parent classloader at runtime.

+ * + * @param session connector session + * @param tableId Doris internal table ID + * @param tableName table display name + * @param dbName database name + * @param remoteName remote table name in the external data source + * @param numCols number of columns in the schema + * @param catalogId Doris internal catalog ID + * @return the table descriptor, or {@code null} if the connector does not + * need a typed descriptor (fe-core will use a generic fallback) + */ + @ConnectorMustImplement + default org.apache.doris.thrift.TTableDescriptor buildTableDescriptor( + ConnectorSession session, + long tableId, String tableName, String dbName, + String remoteName, int numCols, long catalogId) { + return null; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java index 22afc267669d32..60510eb64f2a2c 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java @@ -17,488 +17,37 @@ package org.apache.doris.connector.api; -import org.apache.doris.connector.api.ddl.BranchChange; -import org.apache.doris.connector.api.ddl.ConnectorColumnPath; -import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; -import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; -import org.apache.doris.connector.api.ddl.DropRefChange; -import org.apache.doris.connector.api.ddl.PartitionFieldChange; -import org.apache.doris.connector.api.ddl.TagChange; -import org.apache.doris.connector.api.handle.ConnectorColumnHandle; -import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; -import org.apache.doris.connector.api.pushdown.ConnectorExpression; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; - /** - * Operations on tables within a connector catalog. + * Operations on tables within a connector catalog: the aggregate of the per-domain table interfaces. + * + *

This interface declares nothing itself. It exists so that {@link ConnectorMetadata} keeps one + * table-operations supertype and connectors keep compiling unchanged, while the operations themselves live in + * the domain each belongs to:

+ * + *
    + *
  • {@link ConnectorTableMetadataOps} — resolving a table by name and reading what it looks like. The + * one domain no connector can skip.
  • + *
  • {@link ConnectorViewOps} — views.
  • + *
  • {@link ConnectorTableDdlOps} — create / drop / rename / truncate.
  • + *
  • {@link ConnectorColumnEvolutionOps} — column schema evolution, flat and nested.
  • + *
  • {@link ConnectorSnapshotRefOps} — branches, tags and partition-spec evolution.
  • + *
  • {@link ConnectorPartitionListingOps} — enumerating partitions.
  • + *
+ * + *

Passing a SQL string through to the remote source is deliberately NOT here: it is the escape hatch of a + * connector whose source speaks SQL, not a table operation, and it lives in the optional + * {@link ConnectorPassthroughSqlOps}, which a connector implements or does not.

+ * + *

Start with each domain's class javadoc: it states that domain's minimum implementation set + * — which methods a connector must override to work at all, which become mandatory once a given + * capability is declared, and which are optional. Every method has a default body, so the compiler demands + * nothing; those lists are the only statement of what is actually required.

*/ -public interface ConnectorTableOps { - - /** Retrieves a table handle for the given database and table name. */ - default Optional getTableHandle( - ConnectorSession session, String dbName, - String tableName) { - return Optional.empty(); - } - - /** - * Lists the system-table names supported for the given base table - * (e.g. ["snapshots", "schemas", "options", "audit_log", "binlog"]). - * - *

The names are WITHOUT any "$" prefix; fe-core composes the - * "{baseTable}${sysName}" reference name. Default: empty (no system - * tables). Implemented by connectors that expose system tables.

- */ - default List listSupportedSysTables(ConnectorSession session, - ConnectorTableHandle baseTableHandle) { - return Collections.emptyList(); - } - - /** - * Returns a handle for the named system table of the given base table, - * or empty if this connector does not expose that system table. - * - *

The returned handle is connector-internal and carries whatever the - * connector needs (system-table name, scan-routing hints, etc.); it is - * opaque to fe-core. {@code sysName} is the bare name (no "$").

- */ - default Optional getSysTableHandle(ConnectorSession session, - ConnectorTableHandle baseTableHandle, String sysName) { - return Optional.empty(); - } - - /** - * Whether the named system table of {@code baseTableHandle} is served by the generic - * {@code partition_values} table-valued function (fe-core's {@code PartitionsSysTable}) rather - * than by a native connector scan. Default {@code false} (native, the {@link #getSysTableHandle} - * path). - * - *

A connector whose partitioned tables expose their partition rows through the generic - * partition-values TVF (e.g. hive) overrides this to return {@code true} for that sys-table name; - * such a name need NOT return a handle from {@link #getSysTableHandle} (the TVF path never consults - * it). fe-core needs the kind at discovery time (before any handle is fetched), so it cannot be - * inferred from an empty {@code getSysTableHandle}. {@code sysName} is the bare name (no - * {@code "$"}).

- */ - default boolean isPartitionValuesSysTable(ConnectorSession session, - ConnectorTableHandle baseTableHandle, String sysName) { - return false; - } - - /** Returns the schema (columns, format, etc.) for the given table. */ - default ConnectorTableSchema getTableSchema( - ConnectorSession session, ConnectorTableHandle handle) { - throw new DorisConnectorException( - "getTableSchema not implemented"); - } - - /** - * Returns the schema AT {@code snapshot.getSchemaId()} — the schema as of the - * pinned snapshot, for time-travel reads under schema evolution. - * - *

The default ignores the snapshot and returns the latest schema via - * {@link #getTableSchema(ConnectorSession, ConnectorTableHandle)}. A connector that - * supports schema-at-snapshot overrides this to resolve the schema version.

- */ - default ConnectorTableSchema getTableSchema( - ConnectorSession session, ConnectorTableHandle handle, - ConnectorMvccSnapshot snapshot) { - return getTableSchema(session, handle); - } - - /** - * Renders the native {@code SHOW CREATE TABLE} DDL for a table, fetching schema FRESH from the underlying - * metastore at call time (bypassing any connector-side table cache) so the returned statement always - * reflects the latest remote schema. - * - *

This is a LAZY, per-call interception point used ONLY by {@code ShowCreateTableCommand}. It intentionally - * does NOT participate in the {@code SUPPORTS_SHOW_CREATE_DDL} capability (which gates the engine-assembled - * DDL in {@code Env.getDdlStmt} for every caller, including delegated sibling tables and the HTTP schema - * endpoint). A connector that does not natively render its own SHOW CREATE returns {@link Optional#empty()}, - * and the command falls through to the generic {@code Env.getDdlStmt} path unchanged.

- * - * @return the full {@code CREATE TABLE} statement, or {@link Optional#empty()} to defer to the engine - */ - default Optional renderShowCreateTableDdl( - ConnectorSession session, ConnectorTableHandle handle) { - return Optional.empty(); - } - - /** Returns a name-to-handle map for all columns of the table. */ - default Map getColumnHandles( - ConnectorSession session, ConnectorTableHandle handle) { - throw new DorisConnectorException( - "getColumnHandles not implemented"); - } - - /** - * Returns a name-to-handle map for all columns AT {@code snapshot.getSchemaId()} — the - * columns as of the pinned snapshot, for time-travel reads under schema evolution. - * - *

The default ignores the snapshot and returns the latest columns via - * {@link #getColumnHandles(ConnectorSession, ConnectorTableHandle)}. WHY this exists: the generic - * scan node builds column handles BEFORE it pins the snapshot, so without a snapshot-aware overload - * it can only key handles by the LATEST names. A time-travel query binds its slots to the PINNED - * (old) names, so after a RENAME the renamed column's slot misses the latest-keyed map and is - * silently dropped — crashing BE with a field-id dictionary miss on connectors whose native - * projection is name/ordinal-driven (paimon). A connector that supports schema-at-snapshot - * overrides this to key handles by the pinned names (mirrors the - * {@link #getTableSchema(ConnectorSession, ConnectorTableHandle, ConnectorMvccSnapshot)} split) and - * declares {@link #supportsColumnHandleSnapshotPin(ConnectorSession)}.

- */ - default Map getColumnHandles( - ConnectorSession session, ConnectorTableHandle handle, - ConnectorMvccSnapshot snapshot) { - return getColumnHandles(session, handle); - } - - /** - * Whether {@link #getColumnHandles(ConnectorSession, ConnectorTableHandle, ConnectorMvccSnapshot)} - * resolves handles AT the pinned snapshot's schema (i.e. keys them by the pinned names). - * - *

Only a connector that returns {@code true} is subject to the generic node's fail-loud check - * that every bound column present in the pinned schema has a handle: for such a connector a missing - * pinned column is a genuine bug (the connector promised pinned handles but dropped one) and must - * fail with a clear error rather than being silently dropped into a BE crash. A connector that - * returns {@code false} keeps the legacy latest-keyed handles and recovers from the drop by its own - * means (e.g. iceberg rebuilds its field-id dictionary from the full pinned schema), so it is left - * on the unchanged silent-skip path.

- */ - default boolean supportsColumnHandleSnapshotPin(ConnectorSession session) { - return false; - } - - /** Lists all table names within the given database. */ - default List listTableNames(ConnectorSession session, - String dbName) { - return Collections.emptyList(); - } - - /** - * Returns whether the named view exists in the given database. Connectors that expose views - * (declaring {@link ConnectorCapability#SUPPORTS_VIEW}) override this; the default {@code false} - * keeps view-less connectors reporting every object as a non-view. - */ - default boolean viewExists(ConnectorSession session, String dbName, String viewName) { - return false; - } - - /** - * Lists all view names within the given database. Connectors that subtract views from - * {@link #listTableNames} (e.g. iceberg) expose them here so the catalog can merge them back into - * {@code SHOW TABLES}; the default is empty (no view support). - */ - default List listViewNames(ConnectorSession session, String dbName) { - return Collections.emptyList(); - } - - /** - * Loads the {@link ConnectorViewDefinition stored SQL definition + dialect} of the named view. Connectors - * that expose views (declaring {@link ConnectorCapability#SUPPORTS_VIEW}) override this; callers gate on - * {@code SUPPORTS_VIEW} and {@code isView()} so the default — for view-less connectors — fails loud. - * - * @throws DorisConnectorException if the connector does not support views - */ - default ConnectorViewDefinition getViewDefinition(ConnectorSession session, String dbName, String viewName) { - throw new DorisConnectorException("GET VIEW DEFINITION not supported"); - } - - /** - * Drops the named view. Connectors that expose views (declaring {@link ConnectorCapability#SUPPORTS_VIEW}) - * override this; callers route a DROP through {@link #viewExists} so the default — for view-less - * connectors — is unreachable and fails loud as a guard. - * - * @throws DorisConnectorException if the connector does not support views - */ - default void dropView(ConnectorSession session, String dbName, String viewName) { - throw new DorisConnectorException("DROP VIEW not supported"); - } - - /** Creates a new table with the given schema and properties. */ - default void createTable(ConnectorSession session, - ConnectorTableSchema schema, - Map properties) { - throw new DorisConnectorException( - "CREATE TABLE not supported"); - } - - /** - * Creates a table with full DDL semantics (partition, bucket, external, - * {@code IF NOT EXISTS}). - * - *

Connectors should override this when they support advanced - * {@code CREATE TABLE} options. The default degrades to the legacy - * {@link #createTable(ConnectorSession, ConnectorTableSchema, Map)}, - * dropping partition / bucket / external / {@code ifNotExists} info.

- * - * @throws DorisConnectorException if the connector cannot honor the request - */ - default void createTable(ConnectorSession session, - ConnectorCreateTableRequest request) { - ConnectorTableSchema schema = new ConnectorTableSchema( - request.getTableName(), - request.getColumns(), - null, - request.getProperties()); - createTable(session, schema, request.getProperties()); - } - - /** Drops the specified table. */ - default void dropTable(ConnectorSession session, - ConnectorTableHandle handle) { - throw new DorisConnectorException( - "DROP TABLE not supported"); - } - - /** Renames the table identified by {@code handle} to {@code newName} within the same database. */ - default void renameTable(ConnectorSession session, - ConnectorTableHandle handle, String newName) { - throw new DorisConnectorException( - "RENAME TABLE not supported"); - } - - /** - * Truncates the table identified by {@code handle}. When {@code partitions} is non-empty only those - * partitions are truncated; {@code null} / empty truncates the whole table. - * - *

Connectors that support {@code TRUNCATE TABLE} override this. The default throws, matching the - * pre-flip behavior of the generic bridge (which had no truncate route for the SPI path).

- * - * @throws DorisConnectorException if the connector does not support truncate - */ - default void truncateTable(ConnectorSession session, - ConnectorTableHandle handle, List partitions) { - throw new DorisConnectorException( - "TRUNCATE TABLE not supported"); - } - - /** - * Adds a column to the table at the given position. - * - * @param position where to place the column ({@link ConnectorColumnPosition#FIRST} / - * {@link ConnectorColumnPosition#after(String)}); {@code null} appends at the end. - */ - default void addColumn(ConnectorSession session, ConnectorTableHandle handle, - ConnectorColumn column, ConnectorColumnPosition position) { - throw new DorisConnectorException("ADD COLUMN not supported"); - } - - /** Adds multiple columns to the table, appended in order. */ - default void addColumns(ConnectorSession session, ConnectorTableHandle handle, - List columns) { - throw new DorisConnectorException("ADD COLUMNS not supported"); - } - - /** Drops the named column from the table. */ - default void dropColumn(ConnectorSession session, ConnectorTableHandle handle, - String columnName) { - throw new DorisConnectorException("DROP COLUMN not supported"); - } - - /** Renames a column. */ - default void renameColumn(ConnectorSession session, ConnectorTableHandle handle, - String oldName, String newName) { - throw new DorisConnectorException("RENAME COLUMN not supported"); - } - - /** - * Modifies a column's type and/or comment, optionally repositioning it. - * - * @param position where to move the column; {@code null} keeps its current position. - */ - default void modifyColumn(ConnectorSession session, ConnectorTableHandle handle, - ConnectorColumn column, ConnectorColumnPosition position) { - throw new DorisConnectorException("MODIFY COLUMN not supported"); - } - - /** Reorders the table's columns to match the given full ordered list of column names. */ - default void reorderColumns(ConnectorSession session, ConnectorTableHandle handle, - List newOrder) { - throw new DorisConnectorException("REORDER COLUMNS not supported"); - } - - // ---- Nested (dotted-path) column evolution ---- - // #65329 dotted column paths (e.g. `s.b`, `arr.element.c`, `m.value`) are carried neutrally by - // {@link ConnectorColumnPath}; a single-part path targets a top-level column. Connectors that support - // nested column schema evolution (iceberg) override these; others inherit the throwing default. The - // fe-core bridge routes top-level ADD/DROP/RENAME/MODIFY through the flat ops above and reserves these - // {@code *NestedColumn} ops for the nested case, except {@link #modifyColumnComment}, which is the sole - // entrypoint for the new `MODIFY COLUMN ... COMMENT` op (flat and nested alike). Distinct names (rather - // than overloads of the flat String/ConnectorColumn ops) keep {@code Mockito.any()} / null call sites in - // connector tests unambiguous. - - /** - * Adds a field at {@code path} (the full path of the new field: its parent struct plus the new leaf name). - * - * @param position where to place the new field within its parent struct; {@code null} appends at the end. - */ - default void addNestedColumn(ConnectorSession session, ConnectorTableHandle handle, - ConnectorColumnPath path, ConnectorColumn column, ConnectorColumnPosition position) { - throw new DorisConnectorException("nested ADD COLUMN not supported"); - } - - /** Drops the field at {@code path}. */ - default void dropNestedColumn(ConnectorSession session, ConnectorTableHandle handle, - ConnectorColumnPath path) { - throw new DorisConnectorException("nested DROP COLUMN not supported"); - } - - /** Renames the field at {@code path} to {@code newName} (a leaf name, not a path). */ - default void renameNestedColumn(ConnectorSession session, ConnectorTableHandle handle, - ConnectorColumnPath path, String newName) { - throw new DorisConnectorException("nested RENAME COLUMN not supported"); - } - - /** - * Modifies the field at {@code path} (type / comment / nullability), optionally repositioning it within - * its parent struct. - * - * @param position where to move the field; {@code null} keeps its current position. - */ - default void modifyNestedColumn(ConnectorSession session, ConnectorTableHandle handle, - ConnectorColumnPath path, ConnectorColumn column, ConnectorColumnPosition position) { - throw new DorisConnectorException("nested MODIFY COLUMN not supported"); - } - - /** Sets (or clears, with {@code ""}) the comment/doc of the field at {@code path}. */ - default void modifyColumnComment(ConnectorSession session, ConnectorTableHandle handle, - ConnectorColumnPath path, String comment) { - throw new DorisConnectorException("MODIFY COLUMN COMMENT not supported"); - } - - /** Creates or replaces a named branch (snapshot ref) on the table. */ - default void createOrReplaceBranch(ConnectorSession session, ConnectorTableHandle handle, - BranchChange branch) { - throw new DorisConnectorException("CREATE/REPLACE BRANCH not supported"); - } - - /** Creates or replaces a named tag (snapshot ref) on the table. */ - default void createOrReplaceTag(ConnectorSession session, ConnectorTableHandle handle, - TagChange tag) { - throw new DorisConnectorException("CREATE/REPLACE TAG not supported"); - } - - /** Drops a named branch (snapshot ref) from the table. */ - default void dropBranch(ConnectorSession session, ConnectorTableHandle handle, - DropRefChange branch) { - throw new DorisConnectorException("DROP BRANCH not supported"); - } - - /** Drops a named tag (snapshot ref) from the table. */ - default void dropTag(ConnectorSession session, ConnectorTableHandle handle, - DropRefChange tag) { - throw new DorisConnectorException("DROP TAG not supported"); - } - - /** Adds a partition field (column reference + optional transform) to the table's partition spec. */ - default void addPartitionField(ConnectorSession session, ConnectorTableHandle handle, - PartitionFieldChange change) { - throw new DorisConnectorException("ADD PARTITION FIELD not supported"); - } - - /** Drops a partition field from the table's partition spec. */ - default void dropPartitionField(ConnectorSession session, ConnectorTableHandle handle, - PartitionFieldChange change) { - throw new DorisConnectorException("DROP PARTITION FIELD not supported"); - } - - /** Replaces a partition field (removes the old field, adds the new one) in the table's partition spec. */ - default void replacePartitionField(ConnectorSession session, ConnectorTableHandle handle, - PartitionFieldChange change) { - throw new DorisConnectorException("REPLACE PARTITION FIELD not supported"); - } - - /** Returns the primary key column names for the given table. */ - default List getPrimaryKeys(ConnectorSession session, - String dbName, String tableName) { - return Collections.emptyList(); - } - - /** Returns a human-readable comment for the given table. */ - default String getTableComment(ConnectorSession session, - String dbName, String tableName) { - return ""; - } - - /** - * Executes a DML statement (INSERT/UPDATE/DELETE) directly. - * Used for DML passthrough features like CALL EXECUTE_STMT. - */ - default void executeStmt(ConnectorSession session, String stmt) { - throw new DorisConnectorException("executeStmt not supported"); - } - - /** - * Gets column metadata from a query string via PreparedStatement metadata. - * Used for table-valued functions like query(). - */ - default ConnectorTableSchema getColumnsFromQuery(ConnectorSession session, String query) { - throw new DorisConnectorException("getColumnsFromQuery not supported"); - } - - /** - * Builds the Thrift {@code TTableDescriptor} that BE needs for query execution. - * - *

Each connector constructs its own typed descriptor (e.g., {@code TJdbcTable}, - * {@code TEsTable}) and wraps it in a {@code TTableDescriptor}. This keeps - * connector-specific Thrift logic out of fe-core.

- * - *

The Thrift classes are provided by fe-thrift at compile time and loaded - * from the parent classloader at runtime.

- * - * @param session connector session - * @param tableId Doris internal table ID - * @param tableName table display name - * @param dbName database name - * @param remoteName remote table name in the external data source - * @param numCols number of columns in the schema - * @param catalogId Doris internal catalog ID - * @return the table descriptor, or {@code null} if the connector does not - * need a typed descriptor (fe-core will use a generic fallback) - */ - default org.apache.doris.thrift.TTableDescriptor buildTableDescriptor( - ConnectorSession session, - long tableId, String tableName, String dbName, - String remoteName, int numCols, long catalogId) { - return null; - } - - /** - * Lists all partition display names (e.g., {@code "year=2024/month=01"}). - * - *

Should be cheap and avoid loading per-partition metadata.

- */ - default List listPartitionNames(ConnectorSession session, - ConnectorTableHandle handle) { - return Collections.emptyList(); - } - - /** - * Lists partitions matching the optional filter, with full metadata. - * - *

Connectors should push the filter into the metastore / catalog when - * possible. {@code filter} is empty when the caller wants the full list.

- */ - default List listPartitions(ConnectorSession session, - ConnectorTableHandle handle, - Optional filter) { - return Collections.emptyList(); - } - - /** - * Lists distinct partition column value combinations for the given columns. - * - *

Used by the {@code partition_values()} TVF and by column-distinct-value - * optimizations. Inner list order matches {@code partitionColumns}.

- */ - default List> listPartitionValues(ConnectorSession session, - ConnectorTableHandle handle, - List partitionColumns) { - return Collections.emptyList(); - } +public interface ConnectorTableOps extends + ConnectorTableMetadataOps, + ConnectorViewOps, + ConnectorTableDdlOps, + ConnectorColumnEvolutionOps, + ConnectorSnapshotRefOps, + ConnectorPartitionListingOps { } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java index 8a83ad1d95b488..54a002701129e5 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java @@ -19,6 +19,7 @@ import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -33,11 +34,15 @@ public final class ConnectorTableSchema { /** * Common prefix for every FE-internal reserved control key below. The connector uses these keys to pass - * structural info to fe-core (partition columns / primary keys / SHOW CREATE render hints / per-table - * capabilities / distribution columns) INSIDE the same property map that also carries the source table's - * user-facing pass-through properties. The {@code __internal.} prefix keeps them out of the namespace a - * real user table property would ever use, so a source property can never be mistaken for a control key - * (and vice versa). These keys are FE-only — none is forwarded to BE. + * structural info to fe-core (partition columns / SHOW CREATE render hints / distribution columns) INSIDE + * the same property map that also carries the source table's user-facing pass-through properties. The + * {@code __internal.} prefix keeps them out of the namespace a real user table property would ever use, so + * a source property can never be mistaken for a control key (and vice versa). These keys are FE-only — + * none is forwarded to BE. + * + *

A reserved key is the right carrier only for structure that is naturally a name (a column + * name, a pre-rendered SQL clause). Anything drawn from a closed set gets a typed field instead — see + * {@link #getTableCapabilities()}, which used to be a reserved key holding a CSV of enum constant names.

*/ public static final String INTERNAL_KEY_PREFIX = "__internal."; @@ -73,30 +78,6 @@ public final class ConnectorTableSchema { */ public static final String PARTITION_COLUMNS_KEY = INTERNAL_KEY_PREFIX + "partition_columns"; - /** - * Reserved property key carrying a CSV of the table's primary-key column names (paimon). FE-only; - * stripped from the user-facing SHOW CREATE TABLE PROPERTIES(...) block. - */ - public static final String PRIMARY_KEYS_KEY = INTERNAL_KEY_PREFIX + "primary_keys"; - - /** - * Reserved property key carrying a CSV of {@link ConnectorCapability#name()} values that THIS specific - * table supports, refining the connector-wide {@link Connector#getCapabilities()} set per-table. - * - *

A uniform-format connector (e.g. iceberg — every table orc/parquet) declares a scan capability for all - * its tables connector-wide. A heterogeneous connector (e.g. hive — orc/parquet/text/json/csv/view/hudi in - * one catalog) whose eligibility is per-table file-format gated cannot: Top-N lazy materialization and - * nested-column pruning are orc/parquet-only, and blanket-declaring them connector-wide would over-admit a - * text/json table (a correctness bug for nested-column pruning, which reads NULL leaves without field ids). - * Such a connector instead emits the capability name here, per-table, computed from that table's format.

- * - *

fe-core reads it ADDITIVELY (a capability counts as supported if it is in the connector-wide set OR in - * this per-table list) from the already-cached schema — no remote round-trip and no file-format inspection - * in fe-core. Single-format connectors never emit it and are unaffected. Stripped from the user-facing - * SHOW CREATE TABLE PROPERTIES(...) block.

- */ - public static final String PER_TABLE_CAPABILITIES_KEY = INTERNAL_KEY_PREFIX + "connector.per-table-capabilities"; - /** * Reserved property key carrying a CSV of the table's distribution (bucketing) column names, already * lowercased. A heterogeneous connector (hive) whose bucketing varies per table cannot express it as a @@ -117,18 +98,29 @@ public final class ConnectorTableSchema { */ public static final Set RESERVED_CONTROL_KEYS = Collections.unmodifiableSet(new HashSet<>( Arrays.asList( - PARTITION_COLUMNS_KEY, PRIMARY_KEYS_KEY, SHOW_LOCATION_KEY, SHOW_PARTITION_CLAUSE_KEY, - SHOW_SORT_CLAUSE_KEY, PER_TABLE_CAPABILITIES_KEY, DISTRIBUTION_COLUMNS_KEY))); + PARTITION_COLUMNS_KEY, SHOW_LOCATION_KEY, SHOW_PARTITION_CLAUSE_KEY, + SHOW_SORT_CLAUSE_KEY, DISTRIBUTION_COLUMNS_KEY))); private final String tableName; private final List columns; private final String tableFormatType; private final Map properties; + private final Set tableCapabilities; + /** For a connector whose tables all have the same capabilities — the per-table set is empty. */ public ConnectorTableSchema(String tableName, List columns, String tableFormatType, Map properties) { + this(tableName, columns, tableFormatType, properties, Collections.emptySet()); + } + + /** For a connector that refines its capabilities per table — see {@link #getTableCapabilities()}. */ + public ConnectorTableSchema(String tableName, + List columns, + String tableFormatType, + Map properties, + Set tableCapabilities) { this.tableName = Objects.requireNonNull(tableName, "tableName"); this.columns = columns == null ? Collections.emptyList() @@ -137,6 +129,9 @@ public ConnectorTableSchema(String tableName, this.properties = properties == null ? Collections.emptyMap() : Collections.unmodifiableMap(properties); + this.tableCapabilities = tableCapabilities == null || tableCapabilities.isEmpty() + ? Collections.emptySet() + : Collections.unmodifiableSet(EnumSet.copyOf(tableCapabilities)); } public String getTableName() { @@ -155,6 +150,26 @@ public Map getProperties() { return properties; } + /** + * The capabilities THIS table supports on top of the connector-wide {@link Connector#getCapabilities()} + * set. Never null; empty for a connector that does not refine per table. + * + *

A uniform-format connector (e.g. iceberg — every table orc/parquet) declares its capabilities + * connector-wide. A heterogeneous connector (e.g. hive — orc/parquet/text/json/csv/view/hudi in one + * catalog) whose eligibility is per-table file-format gated cannot: Top-N lazy materialization and + * nested-column pruning are orc/parquet-only, and blanket-declaring them connector-wide would over-admit a + * text/json table (a correctness bug for nested-column pruning, which reads NULL leaves without field + * ids). Such a connector computes this set from the table's own format instead.

+ * + *

fe-core reads it ADDITIVELY (a capability counts as supported if it is in the connector-wide set OR + * here) from the already-cached schema — no remote round-trip and no file-format inspection in fe-core. + * Only the capabilities {@link ConnectorCapability} documents as table-scoped are consulted here; a + * catalog-scoped one placed in this set is ignored, so put it in {@link Connector#getCapabilities()}.

+ */ + public Set getTableCapabilities() { + return tableCapabilities; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -167,12 +182,13 @@ public boolean equals(Object o) { return tableName.equals(that.tableName) && columns.equals(that.columns) && Objects.equals(tableFormatType, that.tableFormatType) - && properties.equals(that.properties); + && properties.equals(that.properties) + && tableCapabilities.equals(that.tableCapabilities); } @Override public int hashCode() { - return Objects.hash(tableName, columns, tableFormatType, properties); + return Objects.hash(tableName, columns, tableFormatType, properties, tableCapabilities); } @Override diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableStatistics.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableStatistics.java index a54f949e32c123..db2a7f2fb9b0bf 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableStatistics.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableStatistics.java @@ -20,15 +20,16 @@ import java.util.Objects; /** - * Basic table-level statistics. Use {@link #UNKNOWN} when statistics - * are unavailable. + * Basic table-level statistics. + * + *

A connector that has no statistics at all returns {@code Optional.empty()} from + * {@link ConnectorStatisticsOps#getTableStatistics} — that, not a sentinel instance, is how + * "unavailable" is expressed. Returning a present value with an individual field set to -1 is + * still allowed and means "this one number is unknown" (e.g. hive knows the total size but not + * the row count).

*/ public final class ConnectorTableStatistics { - /** Sentinel value indicating statistics are not available. */ - public static final ConnectorTableStatistics UNKNOWN = - new ConnectorTableStatistics(-1, -1); - private final long rowCount; private final long dataSize; diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTestResult.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTestResult.java index 2249af7e56c407..031f1dff837534 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTestResult.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTestResult.java @@ -17,56 +17,39 @@ package org.apache.doris.connector.api; -import java.util.Collections; -import java.util.Map; import java.util.Objects; /** * Result of a connector connectivity test. * *

Connectors return this from {@link Connector#testConnection} to report - * whether the data source is reachable. Individual sub-components (e.g., - * metastore, object storage) can report separate results via - * {@link #getComponentResults()}.

+ * whether the data source is reachable. A connector that probes several + * sub-components (e.g. metastore plus object storage) reports the first + * failure it hits, in {@link #getMessage()}.

*/ public class ConnectorTestResult { private final boolean success; private final String message; - private final Map componentResults; - private ConnectorTestResult(boolean success, String message, - Map componentResults) { + private ConnectorTestResult(boolean success, String message) { this.success = success; this.message = message; - this.componentResults = componentResults != null - ? Collections.unmodifiableMap(componentResults) - : Collections.emptyMap(); } /** Creates a successful test result. */ public static ConnectorTestResult success() { - return new ConnectorTestResult(true, "OK", null); + return new ConnectorTestResult(true, "OK"); } /** Creates a successful test result with a message. */ public static ConnectorTestResult success(String message) { - return new ConnectorTestResult(true, message, null); + return new ConnectorTestResult(true, message); } /** Creates a failed test result. */ public static ConnectorTestResult failure(String message) { - return new ConnectorTestResult(false, message, null); - } - - /** Creates a test result with sub-component results. */ - public static ConnectorTestResult withComponents( - Map componentResults) { - boolean allSuccess = componentResults.values().stream() - .allMatch(ConnectorTestResult::isSuccess); - return new ConnectorTestResult(allSuccess, - allSuccess ? "All components OK" : "Some components failed", - componentResults); + return new ConnectorTestResult(false, message); } public boolean isSuccess() { @@ -77,24 +60,9 @@ public String getMessage() { return message; } - /** Per-component results (e.g., "metastore" → OK, "storage" → FAIL). */ - public Map getComponentResults() { - return componentResults; - } - @Override public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append(success ? "SUCCESS" : "FAILURE").append(": ").append(message); - if (!componentResults.isEmpty()) { - sb.append(" ["); - componentResults.forEach((name, result) -> - sb.append(name).append("=").append(result.isSuccess() ? "OK" : "FAIL") - .append(", ")); - sb.setLength(sb.length() - 2); - sb.append("]"); - } - return sb.toString(); + return (success ? "SUCCESS" : "FAILURE") + ": " + message; } @Override diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java index 4580eb4cf41ce3..92d0916dd77320 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Objects; /** @@ -50,6 +51,26 @@ * from {@link #equals(Object)}/{@link #hashCode()}. A connector that tracks a stable per-field id (iceberg * field-ids) carries them here so fe-core can stamp the Doris child column tree's {@code uniqueId}, which the * engine's nested access-path rewrite and the BE field-id scan path match nested leaves by.

+ * + *

Shape contract for complex types (enforced in the canonical constructor, so every factory + * and convenience constructor is covered):

+ *
    + *
  • {@code ARRAY} carries exactly one child; {@code MAP} exactly two (key, value).
  • + *
  • {@code STRUCT} carries at least one child, and {@link #getFieldNames()} is a parallel list — + * same length, same order, no null entries. A STRUCT may not omit or partially supply its + * field names.
  • + *
  • No optional per-child list (nullability, comment, field id, comment-specified) may be + * longer than {@link #getChildren()}. Shorter stays legal — those four are read + * index-tolerantly, so a missing entry means "not carried" and falls back to its default.
  • + *
  • The type name is matched case-insensitively, so {@code "Struct"} cannot dodge the check. Any + * other type name is left alone: {@code typeName} is a free-form string with no vocabulary, so + * "this is not a complex type" cannot be inferred from it.
  • + *
+ * + *

This is checked eagerly because a violation is invisible downstream: fe-core's converter fills a + * missing STRUCT field name with {@code "col" + index} and turns a childless ARRAY/MAP into + * {@code ARRAY}/{@code MAP} without any error, so the mistake surfaces much later as + * "field name not found" at query analysis, far from the connector that made it.

*/ public final class ConnectorType { @@ -78,8 +99,8 @@ public final class ConnectorType { // This is the one bit {@link #childrenComments} cannot encode: a Doris STRUCT field stores an omitted // COMMENT as a non-null empty string, so "COMMENT omitted" and "COMMENT ''" collapse to the same comment // value and are distinguishable ONLY by this flag. A connector's nested complex {@code MODIFY COLUMN} diff - // reads it to keep the field's CURRENT doc when the COMMENT was omitted vs clear it when it was "" (#65329 - // omit-preserves-metadata). Unused by CREATE / ADD (a new field has no prior doc), so those paths are + // reads it to keep the field's CURRENT doc when the COMMENT was omitted vs clear it when it was "" + // (omitting preserves, explicit empty clears). Unused by CREATE / ADD (a new field has no prior doc), so those // unaffected. private final List childrenCommentSpecified; @@ -145,6 +166,60 @@ public ConnectorType(String typeName, int precision, int scale, this.childrenCommentSpecified = childrenCommentSpecified == null ? Collections.emptyList() : Collections.unmodifiableList(childrenCommentSpecified); + validateShape(); + } + + /** + * Fails loud on a malformed complex type: see the shape contract in the class javadoc. Runs on the + * already-normalized fields, so every constructor and factory funnels through it. + */ + private void validateShape() { + switch (typeName.toUpperCase(Locale.ROOT)) { + case "ARRAY": + requireChildCount("ARRAY", 1); + break; + case "MAP": + requireChildCount("MAP", 2); + break; + case "STRUCT": + if (children.isEmpty()) { + throw new IllegalArgumentException("STRUCT requires at least one child type"); + } + if (fieldNames.size() != children.size()) { + throw new IllegalArgumentException("STRUCT field name count (" + fieldNames.size() + + ") must match child type count (" + children.size() + ")"); + } + if (fieldNames.contains(null)) { + throw new IllegalArgumentException("STRUCT field names must not contain null: " + fieldNames); + } + break; + default: + // Unknown type name: nothing to assert. Not a complex-type tag as far as we can tell, and + // typeName is free-form, so we must not conclude "then it has no children". + return; + } + requireParallel("children nullability", childrenNullable.size()); + requireParallel("children comments", childrenComments.size()); + requireParallel("children field ids", childrenFieldIds.size()); + requireParallel("children comment-specified flags", childrenCommentSpecified.size()); + } + + private void requireChildCount(String tag, int expected) { + if (children.size() != expected) { + throw new IllegalArgumentException( + tag + " requires exactly " + expected + " child type(s), got " + children.size()); + } + } + + private void requireParallel(String what, int size) { + // Deliberately only rejects "longer than children". A SHORTER list is a documented, supported + // state for these four: every accessor (isChildNullable / getChildComment / getChildFieldId / + // isChildCommentSpecified) reads out of range as "not carried for that index" and falls back to + // its default. Only an entry with no child to belong to is unambiguously a caller bug. + if (size > children.size()) { + throw new IllegalArgumentException(typeName.toUpperCase(Locale.ROOT) + " " + what + " count (" + + size + ") must not exceed child type count (" + children.size() + ")"); + } } /** Factory: simple type with no parameters. */ @@ -242,30 +317,14 @@ public List getChildren() { return children; } + /** + * The STRUCT field names, parallel to {@link #getChildren()} — same length, same order, no nulls + * (see the shape contract in the class javadoc). Empty for non-STRUCT types. + */ public List getFieldNames() { return fieldNames; } - /** The full per-child nullability list (may be empty / shorter than children when unset). */ - public List getChildrenNullable() { - return childrenNullable; - } - - /** The full per-child comment list (may be empty / shorter than children when unset). */ - public List getChildrenComments() { - return childrenComments; - } - - /** The full per-child field-id list (may be empty / shorter than children when unset). */ - public List getChildrenFieldIds() { - return childrenFieldIds; - } - - /** The full per-child comment-specified list (may be empty / shorter than children when unset). */ - public List getChildrenCommentSpecified() { - return childrenCommentSpecified; - } - /** * Whether the comment of the child at {@code index} was explicitly specified. Defaults to {@code true} * (the carried comment is authoritative) when not carried for that index (legacy factories / CREATE / diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewDefinition.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewDefinition.java index 865d2fcfed3268..7f87d9bd0bf6e5 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewDefinition.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewDefinition.java @@ -24,7 +24,7 @@ /** * The neutral definition of a connector view: its stored SQL text, the SQL dialect that text is - * written in, and the view's column schema. Returned by {@code ConnectorTableOps.getViewDefinition} so + * written in, and the view's column schema. Returned by {@code ConnectorViewOps.getViewDefinition} so * fe-core can parse and analyze an external view (e.g. iceberg) AND surface its columns * (DESC / SHOW COLUMNS / information_schema.columns) without knowing the connector's native view types. * Trino-aligned ({@code ConnectorViewDefinition} carries the SQL + dialect + columns as first-class diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewOps.java new file mode 100644 index 00000000000000..d2e49c9572b431 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewOps.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.api; + +import java.util.Collections; +import java.util.List; + +/** + * Views exposed by a connector. + * + *

The whole domain is optional — a connector without views implements nothing here, and the engine + * never enters this domain unless the connector declares {@link ConnectorCapability#SUPPORTS_VIEW} (it checks + * the capability before merging view names into {@code SHOW TABLES}).

+ * + *

Minimum implementation set, once {@code SUPPORTS_VIEW} is declared:

+ *
    + *
  • {@link #viewExists} and {@link #getViewDefinition} — required; the latter's default throws.
  • + *
  • {@link #listViewNames} — required only when {@link ConnectorTableMetadataOps#listTableNames} does NOT + * already include views. A metastore listing that returns views alongside tables needs nothing here; a + * catalog that keeps views in a separate namespace does.
  • + *
  • {@link #dropView} — only for {@code DROP VIEW} support.
  • + *
+ */ +public interface ConnectorViewOps { + + /** + * Returns whether the named view exists in the given database. Connectors that expose views + * (declaring {@link ConnectorCapability#SUPPORTS_VIEW}) override this; the default {@code false} + * keeps view-less connectors reporting every object as a non-view. + */ + @ConnectorMustImplement(when = "the connector declares SUPPORTS_VIEW") + default boolean viewExists(ConnectorSession session, String dbName, String viewName) { + return false; + } + + /** + * Lists all view names within the given database. Connectors that subtract views from + * {@link ConnectorTableMetadataOps#listTableNames} (e.g. iceberg) expose them here so the catalog can + * merge them back into {@code SHOW TABLES}; the default is empty (no view support). + */ + @ConnectorMustImplement(when = "listTableNames does not already include views") + default List listViewNames(ConnectorSession session, String dbName) { + return Collections.emptyList(); + } + + /** + * Loads the {@link ConnectorViewDefinition stored SQL definition + dialect} of the named view. Connectors + * that expose views (declaring {@link ConnectorCapability#SUPPORTS_VIEW}) override this; callers gate on + * {@code SUPPORTS_VIEW} and {@code isView()} so the default — for view-less connectors — fails loud. + * + * @throws DorisConnectorException if the connector does not support views + */ + @ConnectorMustImplement(when = "the connector declares SUPPORTS_VIEW") + default ConnectorViewDefinition getViewDefinition(ConnectorSession session, String dbName, String viewName) { + throw new DorisConnectorException("GET VIEW DEFINITION not supported"); + } + + /** + * Drops the named view. Connectors that expose views (declaring {@link ConnectorCapability#SUPPORTS_VIEW}) + * override this; callers route a DROP through {@link #viewExists} so the default — for view-less + * connectors — is unreachable and fails loud as a guard. + * + * @throws DorisConnectorException if the connector does not support views + */ + @ConnectorMustImplement(when = "the connector supports DROP VIEW") + default void dropView(ConnectorSession session, String dbName, String viewName) { + throw new DorisConnectorException("DROP VIEW not supported"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPath.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPath.java index 0569e8fdbca68c..86a3086898cea9 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPath.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPath.java @@ -25,7 +25,7 @@ /** * The dotted column path targeted by an {@code ALTER TABLE ADD/DROP/RENAME/MODIFY COLUMN} clause, * carried neutrally across the SPI by the {@code ConnectorColumnPath} column-DDL overloads on - * {@link org.apache.doris.connector.api.ConnectorTableOps}. + * {@link org.apache.doris.connector.api.ConnectorColumnEvolutionOps}. * *

Faithful, lossless neutralization of the fe-core {@code org.apache.doris.analysis.ColumnPath} * (an ordered, non-empty list of identifier parts). A single-part path targets a top-level column; diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPosition.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPosition.java index 4ab5fafc536c6f..18b7d326a2337a 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPosition.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorColumnPosition.java @@ -21,8 +21,8 @@ /** * The position of a column in an {@code ALTER TABLE ADD/MODIFY COLUMN} clause, carried neutrally - * across the SPI by {@link org.apache.doris.connector.api.ConnectorTableOps#addColumn} / - * {@link org.apache.doris.connector.api.ConnectorTableOps#modifyColumn}. + * across the SPI by {@link org.apache.doris.connector.api.ConnectorColumnEvolutionOps#addColumn} / + * {@link org.apache.doris.connector.api.ConnectorColumnEvolutionOps#modifyColumn}. * *

Faithful, lossless neutralization of the fe-catalog {@code ColumnPosition}, which is exactly * {@code FIRST | AFTER } (there is no {@code BEFORE} variant). The connector taking a diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java index 78bd956db0158e..7f114fac39249f 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java @@ -27,12 +27,12 @@ /** * Full {@code CREATE TABLE} payload passed to - * {@code ConnectorTableOps.createTable(session, request)}. + * {@code ConnectorTableDdlOps.createTable(session, request)}. * - *

Carries partition / bucket / external / {@code IF NOT EXISTS} information - * absent from the legacy - * {@code createTable(session, ConnectorTableSchema, Map)} - * signature.

+ *

Carries everything the statement said: the columns, the partition and bucket specs, + * {@code IF NOT EXISTS} and the user properties. A connector that cannot honor one of them must reject it + * rather than ignore it — reporting success after dropping the partition spec produces a table the user did + * not ask for, with no error to go on.

* *

{@code partitionSpec} and {@code bucketSpec} are nullable when the * underlying DDL omits them.

@@ -48,7 +48,6 @@ public final class ConnectorCreateTableRequest { private final String comment; private final Map properties; private final boolean ifNotExists; - private final boolean external; private ConnectorCreateTableRequest(Builder b) { this.dbName = Objects.requireNonNull(b.dbName, "dbName"); @@ -66,7 +65,6 @@ private ConnectorCreateTableRequest(Builder b) { ? Collections.emptyMap() : Collections.unmodifiableMap(b.properties); this.ifNotExists = b.ifNotExists; - this.external = b.external; } public String getDbName() { @@ -112,10 +110,6 @@ public boolean isIfNotExists() { return ifNotExists; } - public boolean isExternal() { - return external; - } - public static Builder builder() { return new Builder(); } @@ -126,7 +120,6 @@ public String toString() { + ", cols=" + columns.size() + ", partition=" + partitionSpec + ", bucket=" + bucketSpec - + ", external=" + external + ", ifNotExists=" + ifNotExists + "}"; } @@ -140,7 +133,6 @@ public static final class Builder { private String comment; private Map properties; private boolean ifNotExists; - private boolean external; public Builder dbName(String dbName) { this.dbName = dbName; @@ -190,10 +182,6 @@ public Builder ifNotExists(boolean ifNotExists) { return this; } - public Builder external(boolean external) { - this.external = external; - return this; - } public ConnectorCreateTableRequest build() { return new ConnectorCreateTableRequest(this); diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionSpec.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionSpec.java index 8397fe271d3f84..ee484a38388b27 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionSpec.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionSpec.java @@ -31,8 +31,6 @@ *
  • {@code LIST} — Doris {@code PARTITION BY LIST} with explicit value definitions.
  • *
  • {@code RANGE} — Doris {@code PARTITION BY RANGE} with [lower, upper) tuples.
  • * - * - *

    {@code initialValues} is only meaningful for {@code LIST} / {@code RANGE} styles.

    */ public final class ConnectorPartitionSpec { @@ -45,26 +43,19 @@ public enum Style { private final Style style; private final List fields; - private final List initialValues; private final boolean hasExplicitPartitionValues; - public ConnectorPartitionSpec(Style style, - List fields, - List initialValues) { - this(style, fields, initialValues, false); + public ConnectorPartitionSpec(Style style, List fields) { + this(style, fields, false); } public ConnectorPartitionSpec(Style style, List fields, - List initialValues, boolean hasExplicitPartitionValues) { this.style = Objects.requireNonNull(style, "style"); this.fields = fields == null ? Collections.emptyList() : Collections.unmodifiableList(fields); - this.initialValues = initialValues == null - ? Collections.emptyList() - : Collections.unmodifiableList(initialValues); this.hasExplicitPartitionValues = hasExplicitPartitionValues; } @@ -76,17 +67,13 @@ public List getFields() { return fields; } - public List getInitialValues() { - return initialValues; - } - /** * Whether the CREATE TABLE declared explicit partition value definitions (e.g. - * {@code PARTITION BY LIST(dt) (PARTITION p1 VALUES IN ('a'))}). The neutral converter does not lower - * those value expressions into {@link #getInitialValues()} (it stays empty), so this flag preserves the - * information a connector needs to reject them: Hive external tables discover partitions from the data - * layout and reject explicit partition values (legacy parity). Connectors that accept explicit partition - * definitions ignore this flag. + * {@code PARTITION BY LIST(dt) (PARTITION p1 VALUES IN ('a'))}). The neutral converter does not carry + * those value expressions across the SPI boundary at all, so this flag is the only thing that preserves + * the information a connector needs to reject them: Hive external tables discover partitions from the + * data layout and reject explicit partition values (legacy parity). Connectors that accept explicit + * partition definitions ignore this flag. */ public boolean hasExplicitPartitionValues() { return hasExplicitPartitionValues; @@ -103,20 +90,18 @@ public boolean equals(Object o) { ConnectorPartitionSpec that = (ConnectorPartitionSpec) o; return style == that.style && hasExplicitPartitionValues == that.hasExplicitPartitionValues - && fields.equals(that.fields) - && initialValues.equals(that.initialValues); + && fields.equals(that.fields); } @Override public int hashCode() { - return Objects.hash(style, fields, initialValues, hasExplicitPartitionValues); + return Objects.hash(style, fields, hasExplicitPartitionValues); } @Override public String toString() { return "ConnectorPartitionSpec{style=" + style + ", fields=" + fields - + ", initialValues=" + initialValues.size() + ", hasExplicitPartitionValues=" + hasExplicitPartitionValues + "}"; } } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionValueDef.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionValueDef.java deleted file mode 100644 index e86acaa242b4fb..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionValueDef.java +++ /dev/null @@ -1,77 +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.api.ddl; - -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -/** - * Initial value definition for a Doris-style {@code LIST} or {@code RANGE} - * partition declared in a {@code CREATE TABLE} statement. - * - *

    For {@code LIST} partitions, {@code values} contains the literal list of - * permitted values (each inner list is one tuple matching the partition columns). - * For {@code RANGE} partitions, {@code values} contains exactly two tuples - * representing the [lower, upper) bound.

    - */ -public final class ConnectorPartitionValueDef { - - private final String partitionName; - private final List> values; - - public ConnectorPartitionValueDef(String partitionName, - List> values) { - this.partitionName = Objects.requireNonNull(partitionName, "partitionName"); - this.values = values == null - ? Collections.emptyList() - : Collections.unmodifiableList(values); - } - - public String getPartitionName() { - return partitionName; - } - - public List> getValues() { - return values; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof ConnectorPartitionValueDef)) { - return false; - } - ConnectorPartitionValueDef that = (ConnectorPartitionValueDef) o; - return partitionName.equals(that.partitionName) - && values.equals(that.values); - } - - @Override - public int hashCode() { - return Objects.hash(partitionName, values); - } - - @Override - public String toString() { - return "ConnectorPartitionValueDef{name='" + partitionName - + "', values=" + values + "}"; - } -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorPartitionHandle.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorPartitionHandle.java deleted file mode 100644 index 371d8bf4b3de4c..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorPartitionHandle.java +++ /dev/null @@ -1,26 +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.api.handle; - -import java.io.Serializable; - -/** - * Opaque partition handle. - */ -public interface ConnectorPartitionHandle extends Serializable { -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransaction.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransaction.java index a05adbebf261ce..8ec44b383ecbea 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransaction.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransaction.java @@ -28,11 +28,8 @@ * {@link #rollback()} on failure, then always calls {@link #close()} to * release resources. {@code rollback()} and {@code close()} are safe to * call multiple times.

    - * - *

    Extends the marker {@link ConnectorTransactionHandle} so that existing - * APIs that traffic in opaque handles continue to work without change.

    */ -public interface ConnectorTransaction extends ConnectorTransactionHandle, Closeable { +public interface ConnectorTransaction extends Closeable { /** Stable transaction ID assigned by the connector. */ long getTransactionId(); @@ -76,7 +73,7 @@ default long getUpdateCnt() { /** * Applies an optional engine-extracted, target-only write constraint used for write-time optimistic - * conflict detection (O5-2). The engine extracts, from the analyzed DELETE/UPDATE/MERGE plan, the + * conflict detection. The engine extracts, from the analyzed DELETE/UPDATE/MERGE plan, the * conjuncts that reference only the target table's own columns (slot origin-table == target, excluding * synthetic {@code $row_id} / metadata / join columns) and hands the connector a neutral * {@link ConnectorPredicate} at plan time, before {@code begin}/{@code commit}. diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransactionHandle.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransactionHandle.java deleted file mode 100644 index 6320a186f744de..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransactionHandle.java +++ /dev/null @@ -1,24 +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.api.handle; - -/** - * Opaque transaction handle. - */ -public interface ConnectorTransactionHandle { -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorWriteHandle.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorWriteHandle.java index 2b90af2a70153c..70534c6c3191eb 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorWriteHandle.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorWriteHandle.java @@ -30,7 +30,7 @@ * *

    Carries the engine-resolved facts about a single DML write: the target * table handle, the column list, whether it is an OVERWRITE, and the static - * partition spec ({@link #getWriteContext}). The connector reads these to build + * partition spec ({@link #getStaticPartitionSpec}). The connector reads these to build * its Thrift data sink.

    */ public interface ConnectorWriteHandle { @@ -47,13 +47,14 @@ public interface ConnectorWriteHandle { /** * The static partition spec (partition column name -> value) for a statically partitioned write, * carried from the bound sink to {@code planWrite}; an EMPTY map when the write is not statically - * partitioned. Despite the "write context" name (once envisioned as a free-form bag), the only content - * the sole producer ({@code PluginDrivenTableSink.bindDataSink} -> - * {@code PluginDrivenInsertCommandContext.getStaticPartitionSpec}) ever populates is the static - * partition spec, and the write providers (hive/iceberg/maxcompute) all consume it as such (iceberg - * ships it verbatim as {@code TDataSink.static_partition_values}). + * partitioned. Both sides now spell it the same way: the sole producer is + * {@code PluginDrivenTableSink.bindDataSink} -> {@code PluginDrivenInsertCommandContext + * .getStaticPartitionSpec}, and the write providers (hive/iceberg/maxcompute) consume it as such + * (iceberg ships it verbatim as {@code TDataSink.static_partition_values}). It was once called + * {@code getWriteContext} and envisioned as a free-form bag; nothing ever put anything else in it, so the + * name was corrected rather than the contract widened. A future free-form channel would be a new method. */ - Map getWriteContext(); + Map getStaticPartitionSpec(); /** * The kind of DML write (INSERT / OVERWRITE / DELETE / UPDATE / MERGE). A single diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/RewriteCapableTransaction.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/RewriteCapableTransaction.java index b292eadf56e666..0071328efa8bfc 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/RewriteCapableTransaction.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/RewriteCapableTransaction.java @@ -20,13 +20,13 @@ import java.util.Set; /** - * Narrow opt-in capability for a {@link ConnectorTransaction} that supports the compaction - * {@code rewrite_data_files} procedure (only iceberg today). + * Narrow opt-in capability for a {@link ConnectorTransaction} that supports a distributed compaction + * rewrite procedure (one connector implements one today). * - *

    Kept OFF {@link ConnectorTransaction} so the shared transaction contract carries no source-specific - * methods: the engine rewrite driver ({@code ConnectorRewriteDriver}) checks {@code instanceof - * RewriteCapableTransaction} before it calls, turning "unsupported" from a runtime throw into a type - * mismatch. A connector whose transaction is not rewrite-capable simply does not implement this.

    + *

    Kept OFF {@link ConnectorTransaction} so the shared transaction contract carries no methods only some + * connectors can honor: the engine rewrite driver probes for this capability before it calls, turning + * "unsupported" from a runtime throw into a type mismatch. A connector whose transaction is not + * rewrite-capable simply does not implement this.

    */ public interface RewriteCapableTransaction { @@ -45,8 +45,8 @@ public interface RewriteCapableTransaction { /** * Compaction rewrite: the number of new compacted data files this transaction added, available only * AFTER {@code ConnectorTransaction#commit()} (the count is materialized from the BE-reported commit - * fragments during commit). Feeds the procedure's {@code added_data_files_count} result column — the one - * rewrite-result statistic the engine driver cannot compute from its planning groups. + * fragments during commit). This is the one rewrite statistic the engine cannot compute from the planning + * groups, which is why it has to be reported back rather than summed. */ int getRewriteAddedDataFilesCount(); } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java index 993ec0e2693784..5226742a954ee6 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java @@ -26,7 +26,7 @@ * refresh path of a plugin-driven MVCC table. * *

    The generic table model ({@code PluginDrivenMvccExternalTable}) builds its partition view from - * {@link org.apache.doris.connector.api.ConnectorTableOps#listPartitions} by default, which always + * {@link org.apache.doris.connector.api.ConnectorPartitionListingOps#listPartitions} by default, which always * yields {@code LIST} partitions keyed on a last-modified timestamp. Connectors whose partitions are * intrinsically ranges (e.g. iceberg's {@code YEAR}/{@code MONTH}/{@code DAY}/{@code HOUR} time * transforms) need {@code RANGE} partition items plus a snapshot-id freshness marker to preserve the diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java index 39e0296edb28cf..53fbad7dddfe8d 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java @@ -28,22 +28,22 @@ * *

    Returned by {@code ConnectorMetadata.beginQuerySnapshot} and friends. * Used by the engine as the MVCC pin for all subsequent reads of the same - * table handle within a query, and serialized into BE scan ranges so the - * read path sees a consistent version.

    + * table handle within a query.

    + * + *

    The pin lives entirely inside FE: this type is not serializable and the engine never places it in a + * scan range. What reaches BE is whatever the connector itself put there — the engine hands the snapshot back + * to the connector ({@code ConnectorMetadata.applySnapshot}), the connector weaves the version into its own + * table handle, and its scan plan provider decides what to emit.

    */ public final class ConnectorMvccSnapshot { private final long snapshotId; - private final long timestampMillis; - private final String description; private final long schemaId; private final Map properties; private final boolean lastModifiedFreshness; private ConnectorMvccSnapshot(Builder b) { this.snapshotId = b.snapshotId; - this.timestampMillis = b.timestampMillis; - this.description = b.description; this.schemaId = b.schemaId; this.properties = b.properties.isEmpty() ? Collections.emptyMap() @@ -56,16 +56,6 @@ public long getSnapshotId() { return snapshotId; } - /** Wall-clock time at which the snapshot was committed, in ms since epoch. */ - public long getTimestampMillis() { - return timestampMillis; - } - - /** Optional human-readable description; may be empty, never null. */ - public String getDescription() { - return description; - } - /** * Schema version of this snapshot (e.g. paimon schemaId). {@code -1} = unknown * ⇒ schema-aware reads fall back to the latest schema. @@ -74,7 +64,11 @@ public long getSchemaId() { return schemaId; } - /** Connector-specific metadata propagated to BE. Unmodifiable, never null. */ + /** + * Connector-specific metadata carried alongside the snapshot, read back only by the connector that + * produced it (in {@code applySnapshot}, and in hudi's synthetic-predicate hook). fe-core never reads + * these entries and never forwards them anywhere. Unmodifiable, never null. + */ public Map getProperties() { return properties; } @@ -102,24 +96,20 @@ public boolean equals(Object o) { } ConnectorMvccSnapshot that = (ConnectorMvccSnapshot) o; return snapshotId == that.snapshotId - && timestampMillis == that.timestampMillis && schemaId == that.schemaId && lastModifiedFreshness == that.lastModifiedFreshness - && description.equals(that.description) && properties.equals(that.properties); } @Override public int hashCode() { - return Objects.hash(snapshotId, timestampMillis, schemaId, lastModifiedFreshness, description, properties); + return Objects.hash(snapshotId, schemaId, lastModifiedFreshness, properties); } @Override public String toString() { return "ConnectorMvccSnapshot{snapshotId=" + snapshotId - + ", timestampMillis=" + timestampMillis + ", schemaId=" + schemaId - + ", description='" + description + "'" + ", lastModifiedFreshness=" + lastModifiedFreshness + ", properties=" + properties + "}"; } @@ -131,8 +121,6 @@ public static Builder builder() { public static final class Builder { private long snapshotId; - private long timestampMillis; - private String description = ""; private long schemaId = -1; private final Map properties = new HashMap<>(); private boolean lastModifiedFreshness; @@ -148,21 +136,11 @@ public Builder lastModifiedFreshness(boolean lastModifiedFreshness) { return this; } - public Builder timestampMillis(long timestampMillis) { - this.timestampMillis = timestampMillis; - return this; - } - public Builder schemaId(long schemaId) { this.schemaId = schemaId; return this; } - public Builder description(String description) { - this.description = Objects.requireNonNull(description, "description"); - return this; - } - public Builder property(String key, String value) { this.properties.put( Objects.requireNonNull(key, "key"), diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/package-info.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/package-info.java new file mode 100644 index 00000000000000..bbc502ae22d627 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/package-info.java @@ -0,0 +1,193 @@ +// 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. + +/** + * The interfaces and value objects a connector plugin IMPLEMENTS, and the engine consumes. + * + *

    Read this page before adding a connector or extending these interfaces. It records the rules the + * existing connectors follow, and — where a rule is not yet fully honored — says so explicitly instead of + * describing the target state as if it were already true. Every rule below can be checked against the code + * that is cited with it; when the two disagree, the code wins and this page is the bug.

    + * + *

    Rule 1 — declare a capability in exactly one of three layers

    + * + *
      + *
    1. A whole subsystem is present or absent → a getter on {@link Connector} that returns + * {@code null} when the subsystem is absent: {@code getScanPlanProvider()}, + * {@code getWritePlanProvider()}, {@code getProcedureOps()}, {@code getEventSource()}, + * {@code getRestPassthrough()}. These stay {@code null}-returning rather than {@code Optional}: the + * engine already branches on {@code null}, and changing the shape would be pure churn.
    2. + *
    3. A switch inside one subsystem → a {@code supportsXxx()} / {@code requiresXxx()} method on + * THAT subsystem's provider, which is the single and ONLY source of truth. {@link Connector} used to + * carry null-safe mirrors of the write switches so the engine never had to null-check; they were deleted, + * because a mirror is a second overridable answer to one question and a connector overriding one while + * leaving the provider at its default would produce two divergent answers with no compile error. The + * engine fetches the provider and asks it, treating a {@code null} provider as "not supported".
    4. + *
    5. A gate the engine must evaluate during static planning, before any provider is reachable → + * a constant in {@link ConnectorCapability}, resolved additively as + * (connector-level set) ∪ (per-table set). A switch that corresponds one-to-one with a provider does + * NOT belong here. Worked example, already written into the code: {@code SUPPORTS_SORT_ORDER} is an + * enum constant because the engine must decide during analysis whether {@code CREATE TABLE ... ORDER BY} + * is accepted, and no write-plan provider exists at that point; the runtime row-ordering sink trait + * {@code ConnectorWritePlanProvider.requiresFullSchemaWriteOrder()} lives on the provider. Both mention + * "write", but one gates a DDL clause and the other shapes the write path.
    6. + *
    + * + *

    Use {@code Optional} for a missing value and {@code null} only for an absent + * subsystem; they are not two competing idioms for the same thing.

    + * + *

    Current deviations. (a) A new switch must default to {@code false} (opt-in), so that adding a + * capability can never change an existing connector's behavior. One legacy switch violates this and defaults + * to {@code true}: {@code ConnectorPushdownOps.supportsCastPredicatePushdown(ConnectorSession)} — see the + * risk paragraph in its own javadoc, and do not copy its polarity. (b) The {@code supportsXxx()} shape also + * appears on non-provider interfaces ({@code ConnectorPushdownOps}, {@code ConnectorSchemaOps}, + * {@code ConnectorTableOps}); those are per-operation switches on the interface that owns the operation, + * which is consistent with layer 2, but there is no provider object to attach them to. (c) Per-table + * refinement of {@link ConnectorCapability} is honored for only five of the thirteen constants today, and + * each constant's own javadoc states its scope (catalog-only, or catalog ∪ per-table) and why; do not + * assume a newly added constant can be refined per table.

    + * + *

    Rule 2 — which exception to throw, and when to fail loud

    + * + *

    Two engine paths translate connector exceptions into user-facing errors, and they accept different + * types. Getting this wrong does not break anything visibly; it only degrades the error the user sees into an + * internal FE exception.

    + * + *
      + *
    • Metadata, DDL, DML and scan-planning paths → throw {@link DorisConnectorException} (or a + * subclass). It is the family the engine catches and re-wraps (for example into {@code DdlException}) on + * those paths.
    • + *
    • Catalog property validation ({@code ConnectorProvider.validateProperties}) → throw + * {@code IllegalArgumentException}. It is the ONLY type the engine unwraps there + * ({@code PluginDrivenExternalCatalog.checkProperties} catches it and re-throws its message as + * {@code DdlException}); several connectors depend on this and say so at their throw sites. Do not + * "correct" these to {@link DorisConnectorException}.
    • + *
    + * + *

    Everything else ({@code UnsupportedOperationException}, {@code IllegalStateException}, plain + * {@code RuntimeException}) is untranslated and surfaces as an internal error. The one accepted use of + * {@code UnsupportedOperationException} is the "this optional SPI method is not implemented" idiom.

    + * + *

    Subclass {@link DorisConnectorException} when the CALLER must distinguish outcomes, not to build + * a taxonomy for its own sake. Shipped precedent: {@code HiveDirectoryListingException} exists so the scan + * path can catch only a skippable listing failure while a plain {@link DorisConnectorException} + * still fails the query loud.

    + * + *

    Fail loud vs. degrade silently. Anything the user explicitly asked for — DDL, writes, an explicit + * {@code ANALYZE ... WITH SAMPLE} — must fail loud, because a swallowed error there produces a wrong result + * or a wrong statistic that then looks authoritative. Anything the engine attempts opportunistically for + * optimization must degrade silently (return empty / a default, and log), because it must never be able to + * fail a query that would otherwise succeed.

    + * + *

    Rule 3 — thrift types only at the BE protocol boundary

    + * + *

    BE is C++ and has no plugin mechanism, so a payload destined for BE has to be a thrift type. That is the + * only reason thrift appears in this module, and the complete list of places it does is:

    + * + *
      + *
    • {@code api/scan/ConnectorScanPlanProvider} — {@code TFileCompressType}, {@code TFileScanRangeParams}, + * {@code TTableFormatFileDesc}
    • + *
    • {@code api/scan/ConnectorScanRange} — {@code TFileRangeDesc}, {@code TTableFormatFileDesc}
    • + *
    • {@code api/handle/ConnectorWriteHandle} — {@code TSortInfo}
    • + *
    • {@code api/write/ConnectorSinkPlan} — {@code TDataSink}
    • + *
    • {@link ConnectorTableMetadataOps#buildTableDescriptor} — {@code TTableDescriptor} (written as an inline + * fully-qualified name, not an import)
    • + *
    + * + *

    Do not add a method that takes a thrift type as a PARAMETER. A new return value that must carry a BE + * payload may only reuse a type already on that list. {@code fe-thrift} is a {@code provided} dependency + * here: it is supplied by the parent classloader at runtime and is not part of a plugin package.

    + * + *

    Current deviation. {@code fe-connector-spi} deliberately avoids thrift even for BE-bound data + * (it passes an enum NAME as a {@code String}, a neutral broker-address type, and a thrift enum's integer + * VALUE as an {@code int}). That is a local convention in that module, not a module-level invariant: it + * depends on this module, which depends on {@code fe-thrift}. Whether to unify the two styles is out of + * scope here.

    + * + *

    Rule 4 — what belongs in this module and what belongs in {@code fe-connector-spi}

    + * + *

    The split is by WHO IMPLEMENTS the type. A type the connector implements and the engine consumes + * belongs here ({@code fe-connector-api}). A type the engine implements and the connector consumes, + * plus the service-discovery entry point, belongs in {@code fe-connector-spi}. The dependency runs one way, + * {@code spi} → {@code api}; nothing here may reference the {@code spi} package.

    + * + *

    The two module names are inverted relative to common usage (elsewhere, including Trino, + * "SPI" names what the plugin implements and "API" names the engine services it consumes). Here + * {@code fe-connector-api} holds the types a connector implements and {@code fe-connector-spi} holds the + * handful it consumes. The names are deliberately NOT being changed — renaming would touch every connector + * pom and the engine's dependencies for a naming win only — so read the content, not the name.

    + * + *

    Current deviation. {@link ConnectorSession}, {@code ConnectorHttpSecurityHook} and + * {@code ConnectorValidationContext} are engine-implemented yet live here. This is a known misplacement, + * left alone because moving them would rewrite imports in every connector.

    + * + *

    Rule 5 — lifecycle, and which methods run off the query thread

    + * + *
      + *
    • {@link Connector} — one instance per catalog, created once (see its own class javadoc).
    • + *
    • {@link ConnectorMetadata} — exactly one instance per catalog per statement, closed + * deterministically when the statement ends. The engine's single entry point for obtaining one + * ({@code PluginDrivenMetadata}) states this contract; {@link Connector#getMetadata} is not called + * anywhere else in the engine. {@link ConnectorMetadata#close()} must be idempotent, and one instance + * must not be shared across threads.
    • + *
    • {@link ConnectorStatisticsOps} — may run on engine background pools, and the engine pins no + * thread context classloader on any of them. See that interface's class javadoc for the pools and for + * what a connector has to do about it. This is the one contract on this page that causes a crash rather + * than a wrong answer when it is ignored.
    • + *
    + * + *

    Rule 6 — a javadoc statement about ENGINE behavior must cite the engine code

    + * + *

    These interfaces are the only behavioral contract a connector author has: connectors are packaged and + * class-loaded separately and cannot read the engine. A javadoc line that describes what the engine does with + * a value, and is wrong, is worse than no line at all — it produces an implementation that compiles, runs, + * and silently returns wrong results. So when documenting engine behavior, name the engine class (and + * ideally the method) that does it, so the next reader can re-verify instead of trusting the sentence.

    + * + *

    Rule 7 — where a connector's tunable knobs live

    + * + *

    Pick the channel by the SCOPE of the value. Only the second one obliges anyone to touch the engine, so + * a knob that can be catalog-scoped should be.

    + * + *
      + *
    • Per catalog → a key in {@code CREATE CATALOG ... PROPERTIES(...)}. The engine hands the + * whole property map to {@code ConnectorProvider.create} and exposes the same map per query through + * {@link ConnectorSession#getCatalogProperties()}; reject a bad value in + * {@code ConnectorProvider.validateProperties} (see Rule 2 for the exception type). Key names, parsing + * and defaults stay entirely inside the connector — prefix the key with the connector's type name + * so two connectors cannot collide. Shipped precedent: {@code hive.ignore_absent_partitions}, + * {@code hive.enable_hms_events_incremental_sync} and {@code hive.hms_events_batch_size_per_rpc} are + * declared in {@code HiveConnectorProperties} and read in {@code HiveScanPlanProvider} / + * {@code HiveConnector}; those key strings appear nowhere in {@code fe-core}.
    • + *
    • Per FE process (one deployment-level value for every catalog, e.g. a driver directory) + * → an {@code fe.conf} field forwarded through {@code ConnectorContext.getEnvironment()} by + * {@code DefaultConnectorContext.buildEnvironment}. This is the one knob shape that requires an engine + * change per key, so use it only when the value genuinely is not per catalog.
    • + *
    • Per session → read the query's session variables from + * {@link ConnectorSession#getSessionProperties()}. The connector does not declare them; it looks up the + * names it cares about.
    • + *
    + * + *

    There is deliberately no declarative property-descriptor mechanism here. One was carried over + * from Trino ({@code ConnectorPropertyMetadata} plus two {@link Connector} getters) and shipped for several + * releases with zero implementors and zero consumers; it was removed rather than wired up, because in Doris + * it would not have solved the thing it looks like it solves — there is no {@code SET SESSION + * catalog.property} syntax to feed, and per-catalog values already arrive through the map above. If you find + * yourself wanting the engine to validate unknown catalog property keys, that belongs in + * {@code ConnectorProvider.validateProperties}, which already exists and already runs.

    + */ +package org.apache.doris.connector.api; diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java index 77d044e68bb506..8f5b626481ea0a 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java @@ -46,8 +46,14 @@ public interface ConnectorProcedureOps { /** - * Returns the procedure names this connector supports for {@code ALTER TABLE EXECUTE}, used by the - * engine for routing, validation, and {@code SHOW}-style discovery. + * Returns the procedure names this connector supports for {@code ALTER TABLE EXECUTE}. + * + *

    The engine does NOT use this list to route or to validate. Routing is by table type plus the + * execution mode reported by {@link #getExecutionMode}; an unknown procedure name is rejected by the + * connector inside {@link #execute} (the engine's "is this supported" check answers {@code true} + * unconditionally and says so at its call site). The engine's only reader of this list is a + * {@code SHOW}-style discovery helper that has no production caller today, so a name omitted here still + * executes and a name added here does not by itself become reachable.

    */ List getSupportedProcedures(); @@ -69,7 +75,8 @@ default ProcedureExecutionMode getExecutionMode(String procedureName) { * Plans a {@link ProcedureExecutionMode#DISTRIBUTED} rewrite into bin-packed groups of data files for the * engine rewrite driver to execute (one {@code INSERT-SELECT} per group). The connector owns the * file-selection / grouping decision and returns it as engine-neutral {@link ConnectorRewriteGroup}s; the - * engine scopes each group's scan to its file paths and sums the per-group stats into the result row. + * engine scopes each group's scan to its file paths, sums the per-group stats, and hands them back to + * {@link #buildRewriteResult} for the connector to render. * *

    Only meaningful for a procedure whose {@link #getExecutionMode} is {@code DISTRIBUTED} * (today: iceberg {@code rewrite_data_files}). The default throws — a connector with no distributed @@ -96,6 +103,33 @@ default List planRewrite( + "' is not one"); } + /** + * Renders what the engine-orchestrated rewrite did into this procedure's result (columns + rows). The + * result SHAPE belongs to the connector for a {@link ProcedureExecutionMode#DISTRIBUTED} procedure exactly + * as it does for a {@code SINGLE_CALL} one, where {@link #execute} returns the + * {@link ConnectorProcedureResult} directly — otherwise the engine would have to carry one connector's + * column names, and a second distributed procedure could only be added by branching on its name inside a + * general engine class. + * + *

    Implementations must render LOCALLY: no table load, no remote call, no authorization scope. The + * engine also calls this on the "nothing to rewrite" path, where it deliberately has not opened a + * transaction (and therefore has nothing to scope an authorized call to).

    + * + *

    Only meaningful for a procedure whose {@link #getExecutionMode} is {@code DISTRIBUTED}. The default + * throws rather than returning an empty result, so a connector that declares {@code DISTRIBUTED} and + * forgets to implement this fails loudly instead of silently answering with an empty result set.

    + * + * @param procedureName the procedure name (case-insensitive at the connector's discretion) + * @param statistics what the rewrite covered and produced + * @return the procedure's result: the columns this procedure declares, and its rows + */ + default ConnectorProcedureResult buildRewriteResult(String procedureName, + ConnectorRewriteStatistics statistics) { + throw new UnsupportedOperationException( + "buildRewriteResult is only implemented for DISTRIBUTED procedures; '" + procedureName + + "' is not one"); + } + /** * Executes a table procedure and returns its result (schema + rows) in an engine-neutral form; the * engine wraps these into a {@code ResultSet}. diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteGroup.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteGroup.java index cf9b94723d54e8..9b0c3278c296e8 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteGroup.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteGroup.java @@ -22,16 +22,16 @@ import java.util.Set; /** - * One bin-packed group of data files a connector's {@code rewrite_data_files} planning produced, in an - * engine-neutral form. The engine rewrite driver runs one {@code INSERT-SELECT} per group, scoping the scan - * to {@link #getDataFilePaths()} (the raw file paths, fed to the connector scan's per-group file scope), and - * sums the per-group stats into the procedure's result row. + * One group of data files a connector's rewrite planning produced, in an engine-neutral form. The engine + * rewrite driver runs one {@code INSERT-SELECT} per group, scoping the scan to {@link #getDataFilePaths()} + * (the raw file paths, fed to the connector scan's per-group file scope), and sums the per-group stats into + * the {@link ConnectorRewriteStatistics} it hands back to the connector. * - *

    This is the planning analogue of {@link ConnectorProcedureResult}: the connector owns the - * file-selection / bin-pack / partition-grouping decision (iceberg's {@code rewrite_data_files} criteria — - * file size range, delete ratio, min-input-files, max group size, partition grouping); the engine only - * orchestrates the distributed reads/writes. No live SDK object crosses the seam — only neutral - * {@code String} paths and primitive counts.

    + *

    The model is atomic replacement by file path: each group's files are read once and replaced by what the + * rewrite writes. This is the planning analogue of {@link ConnectorProcedureResult}: the connector owns which + * files to touch and how to group them (size ranges, delete ratios, minimum inputs, per-partition grouping — + * all of it connector-defined); the engine only orchestrates the distributed reads/writes. No live SDK object + * crosses the seam — only neutral {@code String} paths and primitive counts.

    */ public class ConnectorRewriteGroup { @@ -42,13 +42,11 @@ public class ConnectorRewriteGroup { /** * @param dataFilePaths the RAW file paths of this group's data files (what the connector scan matches a - * per-group file scope against — see the iceberg scan provider); never {@code null} - * @param dataFileCount the number of data files rewritten by this group (feeds {@code - * rewritten_data_files_count}); kept distinct from {@code dataFilePaths.size()} so it - * carries the connector's own count verbatim - * @param totalSizeBytes the total byte size of this group's data files (feeds {@code rewritten_bytes_count}) - * @param deleteFileCount the number of delete files attached to this group (feeds {@code - * removed_delete_files_count}) + * per-group file scope against); never {@code null} + * @param dataFileCount the number of data files rewritten by this group; kept distinct from + * {@code dataFilePaths.size()} so it carries the connector's own count verbatim + * @param totalSizeBytes the total byte size of this group's data files + * @param deleteFileCount the number of delete files attached to this group */ public ConnectorRewriteGroup(Set dataFilePaths, int dataFileCount, long totalSizeBytes, int deleteFileCount) { @@ -64,17 +62,17 @@ public Set getDataFilePaths() { return dataFilePaths; } - /** The number of data files this group rewrites ({@code rewritten_data_files_count} contribution). */ + /** The number of data files this group rewrites. */ public int getDataFileCount() { return dataFileCount; } - /** The total byte size of this group's data files ({@code rewritten_bytes_count} contribution). */ + /** The total byte size of this group's data files. */ public long getTotalSizeBytes() { return totalSizeBytes; } - /** The number of delete files attached to this group ({@code removed_delete_files_count} contribution). */ + /** The number of delete files attached to this group. */ public int getDeleteFileCount() { return deleteFileCount; } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteStatistics.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteStatistics.java new file mode 100644 index 00000000000000..29d25b461006a6 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorRewriteStatistics.java @@ -0,0 +1,82 @@ +// 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.procedure; + +/** + * What a distributed rewrite actually did, handed back to the connector so it can render the procedure's + * result row ({@link ConnectorProcedureOps#buildRewriteResult}). + * + *

    Three of the four — {@code dataFileCount}, {@code totalSizeBytes}, {@code deleteFileCount} — are straight + * sums of the {@link ConnectorRewriteGroup}s the connector itself planned; the engine ran one + * {@code INSERT-SELECT} per group, so counting the groups is part of orchestrating them, and no unit + * conversion or reinterpretation happens on the way. The remaining one, {@code addedDataFileCount}, comes from + * {@code RewriteCapableTransaction#getRewriteAddedDataFilesCount()} and is valid only after commit; it is the + * one figure the engine cannot derive from planning. Note it is the SECOND constructor argument, not the last + * — the constructor follows the result row's conventional column order, not this split. On the "nothing to + * rewrite" path the engine reports all zeros without having opened a transaction.

    + * + *

    The field names mirror {@code ConnectorRewriteGroup}'s getters so the summation is obvious, and the + * constructor order matches the order these values conventionally appear in a rewrite result row, so there is + * one ordering to remember rather than two. All four are neutral words: how they are NAMED and TYPED in the + * result set is the connector's decision, not this class's.

    + */ +public final class ConnectorRewriteStatistics { + + private final int dataFileCount; + private final int addedDataFileCount; + private final long totalSizeBytes; + private final int deleteFileCount; + + /** + * @param dataFileCount total data files the planned groups covered + * @param addedDataFileCount data files the commit produced (post-commit only) + * @param totalSizeBytes total byte size of the data files the planned groups covered + * @param deleteFileCount total delete files attached to the planned groups + */ + public ConnectorRewriteStatistics(int dataFileCount, int addedDataFileCount, long totalSizeBytes, + int deleteFileCount) { + this.dataFileCount = dataFileCount; + this.addedDataFileCount = addedDataFileCount; + this.totalSizeBytes = totalSizeBytes; + this.deleteFileCount = deleteFileCount; + } + + public int getDataFileCount() { + return dataFileCount; + } + + public int getAddedDataFileCount() { + return addedDataFileCount; + } + + public long getTotalSizeBytes() { + return totalSizeBytes; + } + + public int getDeleteFileCount() { + return deleteFileCount; + } + + @Override + public String toString() { + return "ConnectorRewriteStatistics{dataFileCount=" + dataFileCount + + ", addedDataFileCount=" + addedDataFileCount + + ", totalSizeBytes=" + totalSizeBytes + + ", deleteFileCount=" + deleteFileCount + "}"; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorComparison.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorComparison.java index 210cefe200c0b5..dff82dc67b816d 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorComparison.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorComparison.java @@ -24,7 +24,32 @@ /** * Binary comparison: left op right. * - *

    Supported operators: EQ, NE, LT, LE, GT, GE, EQ_FOR_NULL.

    + *

    {@code left} is normally a {@link ConnectorColumnRef} and {@code right} a {@link ConnectorLiteral}, but + * neither is guaranteed: the engine builds this node from whatever the two operands converted to. A connector + * that only supports column-op-literal must check both sides and drop the conjunct otherwise (see the package + * javadoc, Rule 1) rather than assume.

    + * + *

    Operator semantics, all with SQL three-valued logic — a comparison against NULL is UNKNOWN, not false:

    + *
      + *
    • {@code EQ} / {@code NE} / {@code LT} / {@code LE} / {@code GT} / {@code GE} — the ordinary + * {@code =}, {@code !=}, {@code <}, {@code <=}, {@code >}, {@code >=}. Rows where either side is NULL + * match none of them.
    • + *
    • {@code EQ_FOR_NULL} — Doris' null-safe equality {@code <=>}. See below; it has TWO cases.
    • + *
    + * + *

    {@code EQ_FOR_NULL} must be split into two cases; collapsing them loses rows:

    + *
      + *
    • right operand is a NULL literal ({@link ConnectorLiteral#isNull()}) — equivalent to + * {@code IS NULL}.
    • + *
    • right operand is a NON-NULL literal — equivalent to plain {@code EQ}, and specifically NOT + * {@code IS NULL}. Translating {@code c <=> 5} into {@code c IS NULL} silently drops every matching + * row, because the connector prunes the files holding {@code c = 5} before BE ever sees them.
    • + *
    + * + *

    A connector whose dialect has no null-safe form must drop the whole conjunct (package javadoc, Rule 1). + * It must never substitute a narrower predicate. Shipped precedent for the correct shape: the iceberg and + * trino converters branch on {@code isNull()}; the maxcompute converter has no null-safe operator remotely and + * therefore refuses to push the conjunct at all.

    */ public final class ConnectorComparison implements ConnectorExpression { diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLike.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLike.java index 0c11195c205da7..17e09a4a94768b 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLike.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLike.java @@ -23,6 +23,36 @@ /** * A LIKE/REGEXP predicate: {@code value LIKE pattern}. + * + *

    {@code pattern} is normally a {@link ConnectorLiteral} holding a {@code String}, but the engine does not + * guarantee it — a non-literal pattern must be dropped, not guessed at (see the package javadoc, Rule 1).

    + * + *

    {@code LIKE} dialect (Doris semantics — translate to these, not to your remote system's defaults):

    + *
      + *
    • {@code %} matches any run of characters, including the empty run.
    • + *
    • {@code _} matches exactly one character.
    • + *
    • Backslash is the escape character, so {@code \%} and {@code \_} are literal {@code %} / {@code _}. + * The three-argument {@code LIKE ... ESCAPE '!'} form never reaches this node (it arrives as a + * {@code ConnectorFunctionCall} named {@code like} with three arguments — see the package javadoc, + * Rule 6), so a connector handling this node may assume the fixed backslash escape.
    • + *
    + * + *

    {@code REGEXP} is UNANCHORED (Doris/MySQL semantics): the pattern may match anywhere inside the + * value. A remote engine whose regex is whole-string anchored — Lucene's {@code regexp} query, for example — + * is not a valid target for a verbatim hand-off: {@code REGEXP 'bc'} matches {@code 'abcd'} in Doris + * and matches nothing when anchored. Anchoring narrows the predicate, which Rule 1 forbids; either rewrite + * the pattern into the remote form or drop the conjunct.

    + * + *

    Do not turn a pattern into a prefix/suffix/contains match unless it is provably equivalent. + * {@code 'abc%'} is a prefix match. {@code 'a_c%'} is not — {@code _} is a wildcard, so {@code 'abc'} must + * match the pattern but does not start with {@code a_c}. Neither is {@code 'a\%%'} (that is "starts with + * {@code a%}"), nor anything with a {@code %} left in the body. A pushed prefix that is stricter than the + * user's pattern makes the connector skip files, and rows skipped at planning time can never be recovered by + * BE.

    + * + *

    Case folding: do not introduce it. Neither operator carries a collation here, so translate + * case-sensitively. A remote form that matches a case-insensitive SUPERSET is permitted by Rule 1 (BE + * re-checks the original predicate); one that matches fewer rows is not.

    */ public final class ConnectorLike implements ConnectorExpression { diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLiteral.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLiteral.java index 2620d19ecafc8e..2fccc4467c0b65 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLiteral.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLiteral.java @@ -27,8 +27,17 @@ import java.util.Objects; /** - * A literal value expression. The value must be a standard Java type - * (null, Boolean, Integer, Long, Double, String, BigDecimal, LocalDate, LocalDateTime). + * A literal value expression. + * + *

    The value is a standard Java type. The engine produces exactly these eight shapes — see the package + * javadoc (Rule 4) for the full Doris-type-to-Java-class table: {@code null}, {@code Boolean}, {@code Long}, + * {@code Double}, {@code BigDecimal}, {@code String}, {@code LocalDate}, {@code LocalDateTime}.

    + * + *

    Two consequences worth stating outright, because both have produced wrong connector code: + * {@code Integer} never arrives (every integral type, {@code TINYINT} through {@code BIGINT}, is a + * {@code Long}; the {@link #ofInt} factory exists for tests), and {@code LARGEINT} arrives as a decimal + * {@code String}, not as a {@code BigInteger}. A converter that switches on the Java class must have a + * fall-through for the {@code String} case rather than assuming a numeric column carries a numeric object.

    */ public final class ConnectorLiteral implements ConnectorExpression { @@ -82,7 +91,13 @@ public ConnectorType getType() { return type; } - /** Returns the value (may be null for NULL literals). */ + /** + * Returns the value, which is {@code null} for a NULL literal. + * + *

    A NULL literal is a legal operand of any comparison, so check {@link #isNull()} FIRST. In particular + * {@code ConnectorComparison.Operator#EQ_FOR_NULL} means {@code IS NULL} only when this is null and plain + * equality otherwise; conflating the two loses rows (see {@link ConnectorComparison}).

    + */ public Object getValue() { return value; } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorOr.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorOr.java index e72d93cec2688f..398dd395bd2446 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorOr.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorOr.java @@ -17,6 +17,7 @@ package org.apache.doris.connector.api.pushdown; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -31,9 +32,20 @@ public final class ConnectorOr implements ConnectorExpression { private final List disjuncts; + /** + * @param disjuncts two or more disjuncts; fewer is a caller bug, not a degenerate node to absorb + * silently. Consumers translate this node arm by arm, and an arm that never materializes + * narrows the pushed predicate - the failure mode is missing rows, not an error. Copied + * defensively so a caller mutating its own list afterwards cannot change this node, + * matching {@link ConnectorIn}. + */ public ConnectorOr(List disjuncts) { Objects.requireNonNull(disjuncts, "disjuncts"); - this.disjuncts = Collections.unmodifiableList(disjuncts); + if (disjuncts.size() < 2) { + throw new IllegalArgumentException( + "ConnectorOr requires at least two disjuncts, got " + disjuncts.size()); + } + this.disjuncts = Collections.unmodifiableList(new ArrayList<>(disjuncts)); } public List getDisjuncts() { diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorPredicate.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorPredicate.java index 6a7da18a6b6815..6f9c0e0cb89117 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorPredicate.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorPredicate.java @@ -20,8 +20,8 @@ import java.io.Serializable; /** - * A neutral, engine-extracted boolean predicate handed to a connector for write-time conflict detection - * (O5-2). It wraps a {@link ConnectorExpression} — the same engine-neutral expression representation used + * A neutral, engine-extracted boolean predicate handed to a connector for write-time conflict detection. + * It wraps a {@link ConnectorExpression} — the same engine-neutral expression representation used * by scan pushdown — so the connector can convert it to its own predicate dialect without depending on * fe-core / nereids types. * diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/FilterApplicationResult.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/FilterApplicationResult.java index 37b39c9da6615d..332c241df618e6 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/FilterApplicationResult.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/FilterApplicationResult.java @@ -42,6 +42,25 @@ public T getHandle() { return handle; } + /** + * Returns the part of the filter the connector did NOT consume, or {@code null} to claim it consumed + * everything. + * + *

    The engine only acts on {@code null}. {@code PluginDrivenScanNode.convertPredicate} clears + * every conjunct when this is {@code null}, and removes NOTHING when it is non-null — matching residual + * sub-expressions back to individual conjuncts is not implemented, so returning "the half I could not + * push" earns no credit for the half that was pushed. The connector-side consequence is asymmetric:

    + *
      + *
    • non-{@code null} (what all shipped connectors return, usually the original expression): BE + * re-evaluates the predicate, so a slightly WIDER pushdown is still correct.
    • + *
    • {@code null}: nothing re-checks the rows. Claim it only when every conjunct was translated + * EXACTLY — a wide pushdown now returns extra rows, a narrow one still loses them (see the package + * javadoc, Rule 1 and Rule 5).
    • + *
    + * + *

    Per-conjunct credit is available, but through the other protocol: + * {@code ConnectorScanPlanProvider.getScanNodePropertiesResult} reporting not-pushed conjunct indices.

    + */ public ConnectorExpression getRemainingFilter() { return remainingFilter; } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/LimitApplicationResult.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/LimitApplicationResult.java deleted file mode 100644 index 055089cdd40810..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/LimitApplicationResult.java +++ /dev/null @@ -1,70 +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.api.pushdown; - -import java.util.Objects; - -/** - * Result of applying a limit to a table handle. - * - * @param the table handle type - */ -public final class LimitApplicationResult { - - private final T handle; - private final boolean precalculateStatistics; - - public LimitApplicationResult(T handle, - boolean precalculateStatistics) { - this.handle = Objects.requireNonNull(handle, "handle"); - this.precalculateStatistics = precalculateStatistics; - } - - public T getHandle() { - return handle; - } - - public boolean isPrecalculateStatistics() { - return precalculateStatistics; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof LimitApplicationResult)) { - return false; - } - LimitApplicationResult that = (LimitApplicationResult) o; - return precalculateStatistics == that.precalculateStatistics - && handle.equals(that.handle); - } - - @Override - public int hashCode() { - return Objects.hash(handle, precalculateStatistics); - } - - @Override - public String toString() { - return "LimitApplicationResult{handle=" + handle - + ", precalculateStatistics=" - + precalculateStatistics + "}"; - } -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/package-info.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/package-info.java new file mode 100644 index 00000000000000..5c07da50124690 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/package-info.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. + +/** + * The engine-neutral predicate language the engine hands to a connector, and the rules for translating it. + * + *

    This is the ONLY vocabulary a connector receives for filter pushdown. A connector translates it into its + * own dialect (an iceberg {@code Expression}, a paimon {@code Predicate}, an ES query DSL document, a SQL + * {@code WHERE} fragment). Getting a translation subtly wrong does not fail the query — it silently returns + * the wrong number of rows, and {@code EXPLAIN} shows nothing unusual. So read Rule 1 before writing a + * converter; the three shipped row-loss bugs fixed in this area were all the same mistake.

    + * + *

    Rules 1–3 are the contract. Rules 4–6 record what the engine actually produces and what it actually does + * with your answer, each citing the engine class so it can be re-verified (see Rule 6 of + * {@code org.apache.doris.connector.api} — the engine is not on a connector's classpath, so an unverifiable + * sentence here is worse than no sentence).

    + * + *

    Rule 1 — translate exactly, or drop the whole conjunct. Never narrow it

    + * + *
    + *   can express it exactly    -> push it down
    + *   cannot express it exactly -> drop the WHOLE conjunct and let BE evaluate it
    + *   never                     -> push a "close enough" predicate that matches FEWER rows
    + * 
    + * + *

    A pushed predicate that is too WIDE is recoverable: the engine keeps the conjuncts and BE re-evaluates + * them, so the extra rows are filtered out before the user sees them. A pushed predicate that is too NARROW + * is not recoverable by anything: the connector has already skipped files, partitions or row groups, and the + * rows never reach BE. That asymmetry — not convenience — is why dropping is always allowed and narrowing + * never is.

    + * + *

    The precondition on "wide is safe". It holds only while the engine still has the conjuncts. If + * you tell the engine you consumed them (a {@code null} remaining filter, or index-level tracking — Rule 5), + * then a predicate that is too wide starts returning EXTRA rows. Read Rule 5 before claiming full pushdown.

    + * + *

    Two concrete instances of the mistake, both shipped and both fixed:

    + *
      + *
    • {@code c <=> 5} translated to {@code c IS NULL}. Null-safe equality against a NON-NULL literal is plain + * equality; only against a NULL literal is it {@code IS NULL}. Collapsing the two cases returned zero + * rows for a table whose only match was {@code c = 5}. See {@link ConnectorComparison}.
    • + *
    • {@code s LIKE 'a_c%'} translated to "starts with {@code a_c}". {@code _} is a single-character + * wildcard, so {@code 'abc'} matches the user's pattern but not the prefix. See {@link ConnectorLike}.
    • + *
    + * + *

    Rule 2 — which direction is safe depends on what the predicate is FOR

    + * + *

    The same expression tree is used for three purposes, and "drop it" is only safe in two of them:

    + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Safe direction per use
    UseDropping a conjunct meansCorrect behavior
    Scan pushdown ({@link org.apache.doris.connector.api.ConnectorPushdownOps#applyFilter}, + * {@code ConnectorScanPlanProvider.planScan})the filter widens; BE re-evaluates and covers itdropping allowed, narrowing forbidden
    Write-time conflict detection + * ({@link org.apache.doris.connector.api.handle.ConnectorTransaction#applyWriteConstraint})conflict detection widens, i.e. gets more conservativedropping allowed
    {@code ALTER TABLE ... EXECUTE ... WHERE} rewrite scopingMORE files get rewritten — dropping the whole WHERE rewrites the entire tablemust throw; dropping is not allowed
    + * + *

    The engine already enforces the third row on its side: {@code UnboundExpressionToConnectorPredicateConverter} + * is strictly all-or-nothing and throws when any part of the {@code WHERE} cannot be represented neutrally. + * A connector consuming such a predicate must hold the symmetric invariant — a conjunct it cannot turn into + * file pruning is a hard error there, not a silent drop.

    + * + *

    Rule 3 — column names, and what is NOT specified here

    + * + *

    {@link ConnectorColumnRef#getColumnName()} carries the name as the engine knows it. Case handling is + * currently decided per connector and is deliberately not specified: paimon lower-cases both sides + * before matching field names, jdbc maps through its own remote-name table. If your remote system is + * case-sensitive, decide explicitly rather than assuming the engine normalized anything.

    + * + *

    Rule 4 — what the engine actually produces

    + * + *

    One producer. Every tree is built by {@code ExprToConnectorExpressionConverter} (fe-core). The + * other two entry points — {@code NereidsToConnectorExpressionConverter} for DELETE/UPDATE/MERGE and + * {@code UnboundExpressionToConnectorPredicateConverter} for {@code EXECUTE ... WHERE} — route literals and + * types back through it, so literal encoding is identical on all three paths.

    + * + *

    Shape of the root. {@code convertConjuncts} returns the single node directly when there is + * exactly one conjunct (the root is NOT a {@link ConnectorAnd}), and a boolean {@code true} + * {@link ConnectorLiteral} when there are none. Do not assume an {@code AND} root.

    + * + *

    Two arrival paths, only one of which strips CAST.

    + *
      + *
    • {@link ConnectorFilterConstraint} handed to + * {@link org.apache.doris.connector.api.ConnectorPushdownOps#applyFilter} is built by + * {@code PluginDrivenScanNode.buildFilterConstraint} and is NOT filtered: + * {@code supportsCastPredicatePushdown} is not consulted, so predicates wrapping a CAST reach you as-is + * (the converter unwraps the {@code CastExpr} node itself, handing you the inner expression).
    • + *
    • The {@code Optional filter} handed to {@code ConnectorScanPlanProvider.planScan} + * and {@code getScanNodePropertiesResult} comes from {@code PluginDrivenScanNode.buildRemainingFilter}, + * which DOES drop conjuncts containing a CAST when + * {@link org.apache.doris.connector.api.ConnectorPushdownOps#supportsCastPredicatePushdown} is false.
    • + *
    + * + *

    Literal value domain. {@link ConnectorLiteral#getValue()} is one of exactly eight Java shapes. + * Note that {@code Integer} is never produced and that {@code LARGEINT} arrives as a decimal + * {@code String}:

    + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Doris literal type to Java value
    Doris typeJava class
    NULL literal (any type){@code null} — see {@link ConnectorLiteral#isNull()}
    BOOLEAN{@code Boolean}
    TINYINT / SMALLINT / INT / BIGINT{@code Long} (never {@code Integer})
    FLOAT / DOUBLE{@code Double}
    DECIMAL*{@code BigDecimal}
    CHAR / VARCHAR / STRING{@code String}
    DATE / DATEV2{@code LocalDate}
    DATETIME / DATETIMEV2{@code LocalDateTime}
    anything else, incl. LARGEINT, IPV4/IPV6, JSON{@code String} from {@code Expr.getStringValue()}
    + * + *

    Rule 5 — telling the engine what you consumed: two protocols, one of which does nothing

    + * + * + * + * + * + * + * + * + * + * + *
    Residual protocols and their real effect
    What you returnWhat the engine does
    {@link FilterApplicationResult#getRemainingFilter()} == {@code null}{@code PluginDrivenScanNode.convertPredicate} clears ALL conjuncts. BE re-evaluates nothing, so + * every predicate you were given must have been pushed EXACTLY.
    {@code getRemainingFilter()} != {@code null} (any expression, including the original one)Nothing is removed. The engine keeps every conjunct; matching residual sub-expressions back + * to individual conjuncts is not implemented. Returning "the half I did not push" does not give you + * credit for the half you did.
    {@code ScanNodePropertiesResult} with not-pushed conjunct indices{@code PluginDrivenScanNode.pruneConjunctsFromNodeProperties} really does prune: conjuncts whose + * index you did NOT report are removed. This is the only protocol with per-conjunct granularity; + * it is used by exactly one shipped connector (es).
    + * + *

    Consequence for everyone else: all three {@code applyFilter} implementations shipped today return the + * original expression as the residual, so their predicates are also evaluated on BE. That is correct but not + * free — and it is the reason a slightly-too-wide pushdown is survivable today.

    + * + *

    Rule 6 — {@link ConnectorFunctionCall} is also the fallback carrier. Do not match it by name

    + * + *

    {@code ExprToConnectorExpressionConverter.fallback} turns any expression it does not model into a + * {@link ConnectorFunctionCall} whose name is the rendered Doris SQL text and whose argument list is + * empty. It is not a function call at all. Matching function names blindly will make a connector + * translate {@code my_udf(a, b) = 1} as a call to a function literally named {@code "my_udf(a, b) = 1"}. + * jdbc handles this deliberately: an argument-less call whose name is not a plain identifier is emitted as a + * pre-rendered SQL fragment ({@code JdbcQueryBuilder}).

    + * + *

    Two related shapes to expect:

    + *
      + *
    • {@code LIKE ... ESCAPE '!'} (the three-argument form) does not arrive as a + * {@link ConnectorLike} — the converter only builds one for the two-argument form, so it arrives as a + * {@code ConnectorFunctionCall} named {@code like} with three arguments. A connector that only handles + * {@link ConnectorLike} therefore never sees a custom escape character, and may assume the fixed + * backslash escape documented on {@link ConnectorLike}.
    • + *
    • Arithmetic and genuine scalar functions arrive as {@link ConnectorFunctionCall} with real arguments; + * those are safe to match by name.
    • + *
    + * + *

    Anything you cannot interpret confidently falls under Rule 1: drop the conjunct.

    + */ +package org.apache.doris.connector.api.pushdown; diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/rest/ConnectorRestPassthrough.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/rest/ConnectorRestPassthrough.java new file mode 100644 index 00000000000000..6c84270c043fa2 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/rest/ConnectorRestPassthrough.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.api.rest; + +/** + * A connector that can forward an HTTP request to the source it fronts, returning the source's response + * verbatim. Exposed through {@link org.apache.doris.connector.api.Connector#getRestPassthrough()}, which + * returns {@code null} for the connectors that cannot. + * + *

    This exists for FE HTTP endpoints that deliberately speak a specific source's HTTP dialect — today + * {@code ESCatalogAction}, whose two endpoints proxy an Elasticsearch mapping lookup and search. Such an + * endpoint narrows to the catalog type it emulates BEFORE reaching for this capability; the capability itself + * says only "this connector can forward an HTTP request", and it is the caller that knows what shape of + * request the source understands.

    + * + *

    Consequently a connector implementing this is NOT agreeing to serve arbitrary requests from arbitrary + * engine code: the path is composed by an endpoint that was written for that specific source.

    + */ +public interface ConnectorRestPassthrough { + + /** + * Forwards one request and returns the raw response body. + * + * @param path the source-relative path, already composed by the caller in the source's own shape + * @param body the request body, or {@code null} for a GET-style request + * @return the response body, verbatim + */ + String executeRestRequest(String path, String body); +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java index 0a613f0986f132..2738b9d70df754 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java @@ -17,57 +17,36 @@ package org.apache.doris.connector.api.scan; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - +/** + * Doris-canonical constants for partition NAMES. + * + *

    {@link #NULL_PARTITION_NAME} is how "this partition column's value is a genuine SQL NULL" is spelled + * inside a partition NAME. A connector whose source has its own spelling (paimon's + * {@code partition.default-name}, for instance) normalizes to this one when it renders a partition name.

    + * + *

    The literal is frozen byte for byte. It is the historical hive value, and a partition name is a + * persisted, user-visible identity: it lands in view and materialized-view definitions, in the + * {@code partition_values()} table-function output, and in the {@code columns_from_path} bytes BE parses. + * Only the Java symbol may be renamed; changing the string breaks already-persisted objects.

    + * + *

    This constant does not replace the structured null flag on {@code ConnectorPartitionInfo}, and the + * flag does not replace this constant — see {@code PluginDrivenMvccExternalTable#toListPartitionItem}. The flag + * exists so FE can build a TYPED {@code NullLiteral} (parsing this string as an INT or DATE partition value + * would throw and silently drop the partition, making a partitioned table look unpartitioned); the name exists + * for partition identity and for BE's path parsing. Whether a value IS null must be declared by the connector + * through the flag: two connectors render this identical string with different NULL semantics, so fe-core + * never decides nullness by comparing against it.

    + * + *

    There is deliberately no shared "normalize a partition value" helper here. The rule for turning a value + * into a null flag is per-connector — hudi's directory-name rule also treats a literal {@code \N} as NULL, + * which would corrupt real data for a connector whose partition values are typed — so each connector derives + * its own flags (see {@code HudiScanRange}, {@code HiveScanRange}, {@code PaimonScanRange}, + * {@code IcebergScanRange}).

    + */ public final class ConnectorPartitionValues { - public static final String HIVE_DEFAULT_PARTITION = "__HIVE_DEFAULT_PARTITION__"; - public static final String NULL_PARTITION_VALUE = "\\N"; + public static final String NULL_PARTITION_NAME = "__HIVE_DEFAULT_PARTITION__"; private ConnectorPartitionValues() { } - - public static Normalized normalize(List partitionValues) { - if (partitionValues == null || partitionValues.isEmpty()) { - return new Normalized(Collections.emptyList(), Collections.emptyList()); - } - List values = new ArrayList<>(partitionValues.size()); - List isNull = new ArrayList<>(partitionValues.size()); - for (String value : partitionValues) { - boolean nullValue = isNullPartitionValue(value); - values.add(normalizePartitionValue(value)); - isNull.add(nullValue); - } - return new Normalized(values, isNull); - } - - public static boolean isNullPartitionValue(String value) { - return value == null || HIVE_DEFAULT_PARTITION.equals(value) - || NULL_PARTITION_VALUE.equals(value); - } - - public static String normalizePartitionValue(String value) { - return value == null || HIVE_DEFAULT_PARTITION.equals(value) - ? NULL_PARTITION_VALUE : value; - } - - public static final class Normalized { - private final List values; - private final List isNull; - - private Normalized(List values, List isNull) { - this.values = values; - this.isNull = isNull; - } - - public List getValues() { - return values; - } - - public List getIsNull() { - return isNull; - } - } } diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java index a0cb2c4e4fcf25..0931969e1e28ea 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java @@ -40,19 +40,6 @@ */ public interface ConnectorScanPlanProvider { - /** - * Returns the scan range type this provider produces. - * - *

    The engine uses this to determine which Thrift scan range structure - * to generate. For example, {@link ConnectorScanRangeType#FILE_SCAN} - * produces TFileScanRange.

    - * - * @return the scan range type (default: FILE_SCAN) - */ - default ConnectorScanRangeType getScanRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - /** * Whether this connector is PREDICATE-DRIVEN and therefore opts out of the FE prune-to-zero * short-circuit. @@ -127,111 +114,19 @@ default TFileCompressType adjustFileCompressType(TFileCompressType inferred) { } /** - * Plans the scan for the given table, returning a list of scan ranges. - * - * @param session the current session - * @param handle the table handle to scan (may have been updated by applyFilter/applyProjection) - * @param columns the columns to read - * @param filter an optional filter expression (remaining after pushdown) - * @return a list of scan ranges that cover the requested data - */ - List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter); - - /** - * Plans the scan with an optional row limit. + * Plans the scan described by {@code request}, returning the ranges that cover the requested data. * - *

    Some connectors (e.g., JDBC) can push the limit into the remote query - * to reduce data transfer. The default delegates to the 4-arg planScan, - * ignoring the limit.

    + *

    This is the one method a scanning connector must implement. Everything the engine can tell it about + * the scan — columns, remaining filter, row limit, pruned partitions, {@code COUNT(*)} pushdown — arrives + * on {@link ConnectorScanRequest}, and a connector consumes what it can serve and ignores the rest. It + * replaced a chain of four overloads in which only the shortest was abstract, so implementing the obvious + * one silently discarded the limit, the partition pruning and the count signal.

    * * @param session the current session - * @param handle the table handle - * @param columns the columns to read - * @param filter an optional remaining filter expression - * @param limit the maximum number of rows to return, or -1 for no limit - * @return a list of scan ranges - */ - default List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit) { - return planScan(session, handle, columns, filter); - } - - /** - * Plans the scan restricted to a pruned set of partitions. - * - *

    The engine computes partition pruning (Nereids {@code SelectedPartitions}) and - * threads the surviving partitions here so partition-aware connectors can build a read - * session over only those partitions instead of the whole table. The default ignores - * {@code requiredPartitions} and delegates to the 5-arg variant, so connectors that do - * not support partition pushdown are unaffected.

    - * - *

    Contract for {@code requiredPartitions}:

    - *
      - *
    • {@code null} or empty → not pruned; scan ALL partitions (default behavior).
    • - *
    • non-empty → scan ONLY these partitions. Each entry is a partition spec string - * (e.g. {@code "pt=1,region=cn"}), i.e. the keys of the pruned partition map.
    • - *
    - * - *

    The "pruned to zero partitions" case (a partition predicate that matches nothing) is - * short-circuited by the engine before this method is called, so an empty list here always - * means "not pruned / scan all", never "scan nothing".

    - * - * @param session the current session - * @param handle the table handle - * @param columns the columns to read - * @param filter an optional remaining filter expression - * @param limit the maximum number of rows to return, or -1 for no limit - * @param requiredPartitions the pruned partition spec strings, or null/empty for all - * @return a list of scan ranges - */ - default List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit, - List requiredPartitions) { - return planScan(session, handle, columns, filter, limit); - } - - /** - * Plans the scan, signalling whether a no-grouping {@code COUNT(*)} is being pushed down here. - * - *

    When {@code countPushdown} is true, the engine has determined the query is a no-grouping - * {@code COUNT(*)} (Nereids {@code getPushDownAggNoGroupingOp()==COUNT}) and BE is already in - * count mode. A connector that can produce a precomputed row count for (some of) its splits - * should emit it so BE serves the count from metadata instead of materializing rows - * (e.g. Paimon's {@code DataSplit.mergedRowCount()}). The default ignores the flag and delegates - * to the 6-arg variant, so connectors without a metadata row count are unaffected and keep the - * normal scan.

    - * - * @param session the current session - * @param handle the table handle - * @param columns the columns to read - * @param filter an optional remaining filter expression - * @param limit the maximum number of rows to return, or -1 for no limit - * @param requiredPartitions the pruned partition spec strings, or null/empty for all - * @param countPushdown whether a no-grouping {@code COUNT(*)} is being pushed down to this scan - * @return a list of scan ranges + * @param request what to scan and what the engine has already pushed down + * @return the scan ranges covering the requested data */ - default List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit, - List requiredPartitions, - boolean countPushdown) { - return planScan(session, handle, columns, filter, limit, requiredPartitions); - } + List planScan(ConnectorSession session, ConnectorScanRequest request); /** * Whether this connector supports batched / streaming split generation for a partitioned scan. @@ -269,6 +164,23 @@ default boolean supportsTableSample() { return false; } + /** + * Whether the ranges this connector plans are read by BE's NATIVE file readers, so BE's file cache + * applies to them and the engine should run its file-cache admission governance for these tables. + * + *

    {@code false} (the default) keeps a connector out of that governance, which is right for anything + * read through JNI or over a remote protocol: it never populates the BE file cache, so evaluating an + * admission rule for it only spends the lookup. A connector whose ranges BE reads natively (the lake + * formats reading parquet/orc off object storage) returns {@code true}, and does so regardless of which + * catalog type its tables live under — that is the point of asking the connector rather than matching a + * catalog type name. Mirrors {@link #supportsTableSample}'s opt-in shape.

    + * + * @return whether BE file-cache admission governance applies to this connector's scans (default: false) + */ + default boolean supportsFileCache() { + return false; + } + /** * The number of DISTINCT native partitions among the just-planned scan ranges — the count the * connector's SDK actually resolved after ITS full manifest/residual/transform/bucket pruning. @@ -317,28 +229,23 @@ default List collectScanProfiles(ConnectorSession session) * *

    Called once per partition batch when the engine drives batch-mode split generation * (see {@link #supportsBatchScan}). Each call should build a read session over exactly the - * given {@code partitionBatch} and return that batch's scan ranges. The default delegates to - * the 6-arg {@link #planScan} with {@code partitionBatch} as the required partitions, which is - * correct for connectors whose {@code planScan} builds one read session per partition set - * (e.g. MaxCompute). A connector whose {@code planScan} is not partition-set-scoped must - * override this method (and {@link #supportsBatchScan}) before enabling batch mode.

    + * given {@code partitionBatch} and return that batch's scan ranges. The default re-scopes the + * request to {@code partitionBatch} and calls {@link #planScan}, which is correct for connectors + * whose {@code planScan} builds one read session per partition set (e.g. MaxCompute). A connector + * whose {@code planScan} is not partition-set-scoped must override this method (and + * {@link #supportsBatchScan}) before enabling batch mode — inheriting the default would re-plan the + * WHOLE pruned set once per batch and emit every partition's files repeatedly.

    * * @param session the current session - * @param handle the table handle - * @param columns the columns to read - * @param filter an optional remaining filter expression - * @param limit the maximum number of rows to return, or -1 for no limit + * @param request the scan request; its partition set is replaced by this batch * @param partitionBatch the partition spec strings for this batch (non-empty) * @return the scan ranges for this partition batch */ default List planScanForPartitionBatch( ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit, + ConnectorScanRequest request, List partitionBatch) { - return planScan(session, handle, columns, filter, limit, partitionBatch); + return planScan(session, request.withRequiredPartitions(partitionBatch)); } /** @@ -408,6 +315,15 @@ default ConnectorSplitSource streamSplits( * return the query DSL, authentication info, and field context mappings here, * since they are shared across all shard scan ranges.

    * + *

    The keys the engine reads are declared in {@link ScanNodePropertyKeys}; use those constants rather + * than string literals. Any other key is connector-private (the engine passes the map back to + * {@link #populateScanLevelParams} and {@link #appendExplainInfo}, so a connector can carry its own + * information through it) and should be namespaced with the connector's own name.

    + * + *

    Override this face OR {@link #getScanNodePropertiesResult}, not both. The engine only ever + * calls the result face, whose default implementation delegates here; a connector that overrides both + * with different content has the map returned by this method silently discarded.

    + * * @param session the current session * @param handle the table handle (may have been updated by applyFilter) * @param columns the columns to read @@ -422,18 +338,6 @@ default Map getScanNodeProperties( return Collections.emptyMap(); } - /** - * Estimates the number of scan ranges for parallelism planning. - * Returns -1 if the estimate is unknown. - * - *

    The engine may use this to pre-allocate resources or decide - * scan parallelism before calling {@link #planScan}.

    - */ - default long estimateScanRangeCount(ConnectorSession session, - ConnectorTableHandle handle) { - return -1; - } - /** * Returns scan-node-level properties along with filter pushdown results. * @@ -443,8 +347,22 @@ default long estimateScanRangeCount(ConnectorSession session, * refer to the AND children of the filter expression, in the same order as * the conjuncts list.

    * - *

    The default wraps {@link #getScanNodeProperties} with an empty not-pushed set, - * meaning all conjuncts are assumed to have been pushed.

    + *

    The indices are into the conjunct list AS RECEIVED, i.e. after + * {@code PluginDrivenScanNode.buildRemainingFilter} has dropped CAST-wrapped conjuncts when + * {@link org.apache.doris.connector.api.ConnectorPushdownOps#supportsCastPredicatePushdown} is false; the + * engine maps them back to the original positions itself and always keeps the conjuncts it never sent. + * When {@code filter} is a single non-AND node (the engine does not wrap a lone conjunct in an AND), + * index 0 refers to that whole expression.

    + * + *

    The default wraps {@link #getScanNodeProperties} in a result WITHOUT conjunct tracking, which makes + * {@code PluginDrivenScanNode.pruneConjunctsFromNodeProperties} remove nothing: every conjunct is still + * evaluated on BE. That is the safe default — it is NOT "all conjuncts are assumed pushed". Reporting an + * empty not-pushed set through {@link ScanNodePropertiesResult#withPushdownTracking} is the opposite + * claim (everything was pushed, prune them all), so use it only when the pushdown was exact.

    + * + *

    This is the face the engine calls. A connector that needs conjunct tracking overrides this + * one and leaves {@link #getScanNodeProperties} alone — overriding both is how the {@code Map} face's + * result gets silently discarded.

    * * @param session the current session * @param handle the table handle (may have been updated by applyFilter) @@ -457,7 +375,7 @@ default ScanNodePropertiesResult getScanNodePropertiesResult( ConnectorTableHandle handle, List columns, Optional filter) { - return new ScanNodePropertiesResult( + return ScanNodePropertiesResult.of( getScanNodeProperties(session, handle, columns, filter)); } @@ -507,20 +425,6 @@ default List getDeleteFiles(TTableFormatFileDesc tableFormatParams) { return Collections.emptyList(); } - /** - * Returns the serialized table representation for this connector, - * or {@code null} if not applicable. - * - *

    Currently used by Paimon to pass the serialized Paimon Table - * object to BE for JNI-based reading.

    - * - * @param nodeProperties the scan node properties - * @return serialized table string, or null - */ - default String getSerializedTable(Map nodeProperties) { - return null; - } - /** * Releases any per-query read transaction this provider opened, called by the engine when the query * finishes (via the generic query-finish callback registry). The default is a no-op: connectors that do @@ -534,6 +438,15 @@ default String getSerializedTable(Map nodeProperties) { * {@code queryId} is the engine query id string ({@link ConnectorSession#getQueryId()}), the same key the * provider registered the transaction under.

    * + *

    Where that transaction may be kept. This method releases state opened by a DIFFERENT call, yet + * a provider instance cannot carry it: providers are built fresh per acquisition ({@code + * Connector.getScanPlanProvider}), so the instance that opened the transaction is generally not the one + * asked to release it. The state therefore belongs to the CONNECTOR — a connector-scoped, query-id-keyed + * registry that both the opening {@code planScan} and this callback reach through the connector instance + * they were built from (this is what hive does). A provider that stashes the transaction in its own field + * leaks it, and no test will say so. The same rule applies to any other cross-call state a provider + * appears to need: put it on the connector, keyed by query id, and clean it up here.

    + * * @param queryId the finishing query's id (== {@link ConnectorSession#getQueryId()}) */ default void releaseReadTransaction(String queryId) { diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java index 7f2ab1508b4632..902e64691fde47 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java @@ -30,18 +30,17 @@ /** * Represents a unit of work (a split/range) for scanning a connector table. * - *

    Each scan range maps to one BE scan task. The {@link #getRangeType() range type} - * determines how the engine converts this range into the appropriate Thrift - * scan range structure for BE execution.

    + *

    Each scan range maps to one BE scan task. The Thrift shape is chosen by the range itself: override + * {@link #populateRangeParams} to build a format-specific {@code TTableFormatFileDesc} (iceberg, hudi, paimon + * and es all do), and use {@link #getTableFormatType()} to select the BE-side reader. The default + * {@code populateRangeParams} dumps {@link #getProperties()} into the generic {@code jdbc_params} map, which + * is what the JNI-based readers consume.

    * *

    Connectors produce scan ranges via {@link ConnectorScanPlanProvider#planScan}, * and the engine converts them to {@code TScanRangeLocations} for dispatch.

    */ public interface ConnectorScanRange extends Serializable { - /** Returns the scan range type, which determines BE processing. */ - ConnectorScanRangeType getRangeType(); - /** Returns the file path, if applicable. */ default Optional getPath() { return Optional.empty(); @@ -52,7 +51,21 @@ default long getStart() { return 0; } - /** Returns the number of bytes to read, or -1 for the entire file. */ + /** + * Returns this range's size, or -1 when the connector does not quantify it (the default). + * + *

    The unit is connector-defined, NOT universally bytes. A file-backed connector reports the byte + * count to read from {@link #getStart()} (hive, iceberg), but a connector whose split is not a byte range + * reports what its own SDK gave it — MaxCompute's row-offset splits carry a ROW count, and its default + * splits, like Paimon's JNI ranges, carry -1. BE is told the value only through the range's own thrift, so + * each connector stays self-consistent.

    + * + *

    Consequently the ENGINE must not read this as a byte size to drive a generic size-based decision + * (sampling, split merging, parallelism): a value meaning rows would silently mis-size the plan for that + * connector. Such a feature is gated on an explicit capability the connector opts into (mirroring + * {@code supportsTableSample}), and only a connector whose {@code getLength} is genuinely a byte count may + * opt in.

    + */ default long getLength() { return -1; } @@ -60,9 +73,8 @@ default long getLength() { /** * Returns the file format (e.g., "parquet", "orc", "csv", "jni"). * - *

    For {@link ConnectorScanRangeType#FILE_SCAN}, this determines the - * BE reader. For non-file types (JDBC, ES, etc.), return "jni" to use - * the JNI scanner framework.

    + *

    For a range read by a native file reader this determines that reader. For a range served through a + * JNI scanner (jdbc, es, and the system-table paths), return "jni".

    */ default String getFileFormat() { return "jni"; @@ -182,7 +194,6 @@ default boolean isNativeReadRange() { default void populateRangeParams(TTableFormatFileDesc formatDesc, TFileRangeDesc rangeDesc) { Map props = new HashMap<>(getProperties()); - props.put("connector_scan_range_type", getRangeType().name()); props.put("connector_file_format", getFileFormat()); Map partValues = getPartitionValues(); if (partValues != null && !partValues.isEmpty()) { diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRangeType.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRangeType.java deleted file mode 100644 index c0133527750844..00000000000000 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRangeType.java +++ /dev/null @@ -1,47 +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.api.scan; - -/** - * Identifies the type of a {@link ConnectorScanRange}, which determines - * how BE processes the scan range. - * - *

    Each type maps to a specific Thrift scan range variant in the - * execution layer. Connectors choose the appropriate type based on - * their data access pattern:

    - *
      - *
    • {@link #FILE_SCAN} — for file-based connectors (Hive, Iceberg, Paimon, Hudi, Elasticsearch)
    • - *
    • {@link #JDBC_SCAN} — for JDBC connectors
    • - *
    • {@link #REMOTE_OLAP_SCAN} — for remote Doris/OLAP federation
    • - *
    • {@link #CUSTOM} — for connectors with custom scan patterns
    • - *
    - */ -public enum ConnectorScanRangeType { - - /** File-based scan: path + offset + length. Used by Hive, Iceberg, Paimon, Hudi. */ - FILE_SCAN, - - /** JDBC scan: connection info + SQL query. */ - JDBC_SCAN, - - /** Remote OLAP scan: tablet info for Doris federation. */ - REMOTE_OLAP_SCAN, - - /** Custom scan: all information carried in properties map. */ - CUSTOM -} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRequest.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRequest.java new file mode 100644 index 00000000000000..db377ac99ee0cb --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRequest.java @@ -0,0 +1,168 @@ +// 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.scan; + +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Everything the engine knows about one scan when it asks a connector to plan it: + * {@link ConnectorScanPlanProvider#planScan}'s single parameter object. + * + *

    Why a parameter object. These fields arrived one at a time, each as another {@code planScan} + * overload that delegated to the previous one — four in the end, of which only the shortest was abstract. A + * connector that implemented that abstract method, the obvious thing to do when reading the interface, ended + * up silently ignoring the row limit, the pruned partition set and the {@code COUNT(*)} signal: everything + * still worked, just slower, with nothing to point at. There is now one method to implement and one object + * carrying whatever the engine has; a signal added later becomes a field with a default, and no connector + * silently loses it.

    + * + *

    Immutable. The engine builds one per scan; {@link #withRequiredPartitions} makes the batched variant + * ({@link ConnectorScanPlanProvider#planScanForPartitionBatch}) without copying the rest by hand.

    + */ +public final class ConnectorScanRequest { + + private final ConnectorTableHandle tableHandle; + private final List columns; + private final Optional filter; + private final long limit; + private final List requiredPartitions; + private final boolean countPushdown; + + private ConnectorScanRequest(ConnectorTableHandle tableHandle, List columns, + Optional filter, long limit, List requiredPartitions, + boolean countPushdown) { + this.tableHandle = tableHandle; + this.columns = columns; + this.filter = filter; + this.limit = limit; + this.requiredPartitions = requiredPartitions; + this.countPushdown = countPushdown; + } + + /** + * Starts a request for {@code tableHandle} reading {@code columns} — the two facts every scan has. + * Everything else has a default that means "the engine is not asking for anything special". + */ + public static Builder builder(ConnectorTableHandle tableHandle, List columns) { + return new Builder(tableHandle, columns); + } + + /** + * The table to scan. Already carries whatever earlier pushdown steps put on it + * ({@code applyFilter} / {@code applyProjection}, an MVCC snapshot pin, a rewrite-group scope). + */ + public ConnectorTableHandle getTableHandle() { + return tableHandle; + } + + /** The columns to read. */ + public List getColumns() { + return columns; + } + + /** The filter remaining after pushdown, or empty when there is none to push. */ + public Optional getFilter() { + return filter; + } + + /** The maximum number of rows to return, or {@code -1} for no limit. */ + public long getLimit() { + return limit; + } + + /** + * The partitions the engine's pruning left, as metastore-rendered spec strings + * (e.g. {@code "pt=1/region=cn"}); EMPTY means "not pruned — scan every partition". + * + *

    Never means "scan nothing": a predicate that prunes everything away is short-circuited by the engine + * before the connector is asked, except for a predicate-driven connector + * ({@link ConnectorScanPlanProvider#ignorePartitionPruneShortCircuit}), which is handed scan-all and + * re-plans from the filter instead.

    + */ + public List getRequiredPartitions() { + return requiredPartitions; + } + + /** + * Whether a no-grouping {@code COUNT(*)} is being pushed into this scan, so BE is already in count mode. + * A connector that can answer the count from metadata (a per-split precomputed row count) should emit it + * instead of planning ranges that materialize rows; one that cannot ignores this and plans normally. + */ + public boolean isCountPushdown() { + return countPushdown; + } + + /** This request with the partition set replaced — the batched scan's per-batch request. */ + public ConnectorScanRequest withRequiredPartitions(List partitions) { + return new ConnectorScanRequest(tableHandle, columns, filter, limit, + normalizePartitions(partitions), countPushdown); + } + + private static List normalizePartitions(List partitions) { + return partitions == null ? Collections.emptyList() : partitions; + } + + /** Builds a {@link ConnectorScanRequest}; every setter is optional. */ + public static final class Builder { + + private final ConnectorTableHandle tableHandle; + private final List columns; + private Optional filter = Optional.empty(); + private long limit = -1; + private List requiredPartitions = Collections.emptyList(); + private boolean countPushdown; + + private Builder(ConnectorTableHandle tableHandle, List columns) { + this.tableHandle = Objects.requireNonNull(tableHandle, "tableHandle"); + this.columns = Objects.requireNonNull(columns, "columns"); + } + + public Builder filter(Optional filter) { + this.filter = Objects.requireNonNull(filter, "filter"); + return this; + } + + public Builder limit(long limit) { + this.limit = limit; + return this; + } + + /** {@code null} is accepted and means the same as empty: not pruned, scan every partition. */ + public Builder requiredPartitions(List requiredPartitions) { + this.requiredPartitions = normalizePartitions(requiredPartitions); + return this; + } + + public Builder countPushdown(boolean countPushdown) { + this.countPushdown = countPushdown; + return this; + } + + public ConnectorScanRequest build() { + return new ConnectorScanRequest(tableHandle, columns, filter, limit, + requiredPartitions, countPushdown); + } + } +} diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertiesResult.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertiesResult.java index 21a36ef4beff06..4d802d00714380 100644 --- a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertiesResult.java +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertiesResult.java @@ -32,6 +32,14 @@ * AND children of the filter expression, in the same order as the conjuncts list. * Conjuncts whose indices are NOT in this set were successfully pushed down and * will be pruned from the scan node's conjunct list by the engine.

    + * + *

    This is the only residual protocol the engine acts on per conjunct, and exactly one shipped + * connector uses it (es). The other channel — returning a non-null remaining filter from + * {@code ConnectorPushdownOps.applyFilter} — makes the engine keep every conjunct; see + * {@code FilterApplicationResult.getRemainingFilter} and the {@code pushdown} package javadoc, Rule 5. + * Because a pruned conjunct is not re-evaluated anywhere, only report an index as pushed when the + * translation was EXACT: a widened pushdown that would merely cost extra BE work in the other channel + * returns extra rows here.

    */ public class ScanNodePropertiesResult { @@ -39,29 +47,37 @@ public class ScanNodePropertiesResult { private final Set notPushedConjunctIndices; private final boolean hasConjunctTracking; + private ScanNodePropertiesResult(Map properties, + Set notPushedConjunctIndices, boolean hasConjunctTracking) { + this.properties = properties; + this.notPushedConjunctIndices = notPushedConjunctIndices; + this.hasConjunctTracking = hasConjunctTracking; + } + /** - * Creates a result where no fine-grained conjunct tracking is provided. - * The engine will NOT prune any conjuncts. + * Creates a result WITHOUT fine-grained conjunct tracking: the engine prunes nothing and every conjunct + * is still evaluated on BE. This is the safe choice, and the right one unless the connector really did + * translate individual conjuncts exactly. + * + * @param properties scan-node-level properties, keyed per {@link ScanNodePropertyKeys} */ - public ScanNodePropertiesResult(Map properties) { - this.properties = properties; - this.notPushedConjunctIndices = null; - this.hasConjunctTracking = false; + public static ScanNodePropertiesResult of(Map properties) { + return new ScanNodePropertiesResult(properties, null, false); } /** - * Creates a result with explicit not-pushed conjunct tracking. - * An empty set means ALL conjuncts were pushed down and should be pruned. + * Creates a result WITH explicit not-pushed conjunct tracking: every conjunct whose index is absent from + * {@code notPushedConjunctIndices} is pruned from the scan node and never re-evaluated, so an empty set + * claims "all conjuncts were pushed exactly". Only report an index as pushed when the translation was + * exact — a widened pushdown returns extra rows here. * - * @param properties scan-node-level properties - * @param notPushedConjunctIndices indices of conjuncts that were NOT pushed down; - * empty set means all were pushed + * @param properties scan-node-level properties, keyed per {@link ScanNodePropertyKeys} + * @param notPushedConjunctIndices indices of conjuncts that were NOT pushed down; empty set means all + * were pushed */ - public ScanNodePropertiesResult(Map properties, + public static ScanNodePropertiesResult withPushdownTracking(Map properties, Set notPushedConjunctIndices) { - this.properties = properties; - this.notPushedConjunctIndices = notPushedConjunctIndices; - this.hasConjunctTracking = true; + return new ScanNodePropertiesResult(properties, notPushedConjunctIndices, true); } public Map getProperties() { diff --git a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertyKeys.java b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertyKeys.java new file mode 100644 index 00000000000000..39c38f03be5b14 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertyKeys.java @@ -0,0 +1,180 @@ +// 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.scan; + +/** + * The key contract of the scan-node property table — the {@code Map} a connector returns + * from {@link ConnectorScanPlanProvider#getScanNodeProperties} (or wraps in + * {@link ConnectorScanPlanProvider#getScanNodePropertiesResult}). + * + *

    Two directions share that one map:

    + *
      + *
    • connector -> engine: the keys below that the engine reads while building the scan node's + * thrift. A connector emits only the ones it needs; every key is optional.
    • + *
    • engine -> connector: the {@code SYNTHETIC_*} keys the generic scan node injects into the + * copies of the map it hands to {@code appendExplainInfo} and {@code populateScanLevelParams}. They are + * facts only the engine knows (split counts, the pushed-down limit, whether the filtering was fully + * taken); they are never forwarded to BE as properties, though a connector may of course DECIDE + * something from them and write that decision into the thrift it builds.
    • + *
    + * + *

    Keys NOT listed here are connector-private: the engine never reads them, so a connector may use its + * own namespaced keys (e.g. {@code paimon.*}) to carry information from its property builder to its own + * {@code populateScanLevelParams} / {@code appendExplainInfo}. Prefix them with the connector's own name so + * they cannot collide with a future engine-read key.

    + * + *

    Why this class exists: every literal here used to be duplicated between the engine's private constants + * and each connector's inline string. A one-letter mismatch compiles, starts, and returns wrong data + * silently (a mistyped {@link #TEXT_COLUMN_SEPARATOR} puts the whole text row into the first column). The + * literals are the historical wire values and must not be changed — only referenced.

    + */ +public final class ScanNodePropertyKeys { + + // ------------------------------------------------------------------------ + // connector -> engine + // ------------------------------------------------------------------------ + + /** + * Selects the BE file reader. Recognized values: {@code parquet}, {@code orc}, {@code text}, + * {@code csv}, {@code json}, {@code avro}, {@code es_http}. Any other value — including the key being + * absent — selects the JNI reader. + * + *

    This is a SCAN-level decision (BE picks the scanner implementation before it looks at any single + * range), so a connector that reads some ranges natively and some through JNI must not emit + * {@code jni} here; see {@link ConnectorScanRange#isNativeReadRange()}.

    + */ + public static final String FILE_FORMAT_TYPE = "file_format_type"; + + /** + * Comma-separated Doris column names that are encoded in the file PATH rather than in the file body, + * so BE does not try to decode them from the data. Names are taken verbatim (same case the connector + * reported in its schema). + */ + public static final String PATH_PARTITION_KEYS = "path_partition_keys"; + + /** + * Prefix for the storage configuration BE needs to read the files. The engine strips the prefix and + * passes the remaining key/value pairs through as the range's location properties, so + * {@code LOCATION_PREFIX + "s3.access_key"} arrives at BE as {@code s3.access_key}. + */ + public static final String LOCATION_PREFIX = "location."; + + /** + * The remote query text a passthrough connector rendered, used ONLY for the {@code QUERY:} line of + * EXPLAIN. It is not sent to BE through this map. + */ + public static final String REMOTE_QUERY = "query"; + + // ------------------------------------------------------------------------ + // connector -> engine: text-family file attributes + // ------------------------------------------------------------------------ + + /** + * Prefix of the file attributes for the text family of formats (TEXT / CSV / JSON). The literal is the + * historical wire value and is NOT hive-specific: hudi, iceberg and any other connector reading a + * text-family file goes through the same engine code path. + * + *

    Only the suffixes declared below are read by the engine. A connector may put additional + * {@code TEXT_PROPERTY_PREFIX}-prefixed keys in the map for its own consumption, but the engine + * ignores them.

    + */ + public static final String TEXT_PROPERTY_PREFIX = "hive.text."; + + /** Fully qualified name of the SerDe class; selects the text sub-dialect on BE. */ + public static final String TEXT_SERDE_LIB = TEXT_PROPERTY_PREFIX + "serde_lib"; + + /** Number of leading lines to skip (header rows). Parsed as an integer. */ + public static final String TEXT_SKIP_LINES = TEXT_PROPERTY_PREFIX + "skip_lines"; + + /** Column separator. May be a multi-byte string. */ + public static final String TEXT_COLUMN_SEPARATOR = TEXT_PROPERTY_PREFIX + "column_separator"; + + /** Line delimiter. May be a multi-byte string. */ + public static final String TEXT_LINE_DELIMITER = TEXT_PROPERTY_PREFIX + "line_delimiter"; + + /** Separator between a map entry's key and value. */ + public static final String TEXT_MAPKV_DELIMITER = TEXT_PROPERTY_PREFIX + "mapkv_delimiter"; + + /** Separator between the elements of an array / map / struct. */ + public static final String TEXT_COLLECTION_DELIMITER = TEXT_PROPERTY_PREFIX + "collection_delimiter"; + + /** Escape character; a single character. */ + public static final String TEXT_ESCAPE = TEXT_PROPERTY_PREFIX + "escape"; + + /** The literal that represents SQL NULL in the file. */ + public static final String TEXT_NULL_FORMAT = TEXT_PROPERTY_PREFIX + "null_format"; + + /** Quote character enclosing a field; a single character. */ + public static final String TEXT_ENCLOSE = TEXT_PROPERTY_PREFIX + "enclose"; + + /** {@code "true"} to strip the enclosing quotes from field values. */ + public static final String TEXT_TRIM_DOUBLE_QUOTES = TEXT_PROPERTY_PREFIX + "trim_double_quotes"; + + /** {@code "true"} when the rows are JSON documents rather than delimited text. */ + public static final String TEXT_IS_JSON = TEXT_PROPERTY_PREFIX + "is_json"; + + /** {@code "true"} to tolerate malformed JSON rows instead of failing the scan. Read only when + * {@link #TEXT_IS_JSON} is {@code "true"}. */ + public static final String TEXT_OPENX_IGNORE_MALFORMED = TEXT_PROPERTY_PREFIX + "openx_ignore_malformed"; + + // ------------------------------------------------------------------------ + // engine -> connector (never forwarded to BE as a property) + // ------------------------------------------------------------------------ + + /** + * Number of scan ranges this node will read with a native BE reader, counted from + * {@link ConnectorScanRange#isNativeReadRange()}. Injected by the engine into the property map passed to + * {@link ConnectorScanPlanProvider#appendExplainInfo}, for connectors that surface a native/JNI split + * distinction in EXPLAIN. + */ + public static final String SYNTHETIC_NATIVE_READ_SPLITS = "__native_read_splits"; + + /** Total number of scan ranges this node will read. Companion of {@link #SYNTHETIC_NATIVE_READ_SPLITS}. */ + public static final String SYNTHETIC_TOTAL_READ_SPLITS = "__total_read_splits"; + + /** + * Present with value {@code "true"} only while the engine renders a VERBOSE EXPLAIN, so a connector can + * gate VERBOSE-only output from {@link ConnectorScanPlanProvider#appendExplainInfo} without an SPI + * signature change. Absent otherwise. + */ + public static final String SYNTHETIC_EXPLAIN_VERBOSE = "__explain_verbose"; + + /** + * The row limit the engine pushed down to this scan, as a decimal string; {@code "-1"} (or any + * non-positive value) means there is none. Injected on BOTH the {@code appendExplainInfo} and the + * {@code populateScanLevelParams} paths, because a connector that can ask its source to stop early needs + * it while building thrift, not only while printing EXPLAIN. + */ + public static final String SYNTHETIC_PUSHDOWN_LIMIT = "__pushdown_limit"; + + /** + * {@code "true"} when NO filtering is left for the engine to do after the scan — every conjunct was taken + * by the connector. Companion of {@link #SYNTHETIC_PUSHDOWN_LIMIT}: limiting rows at the source is only + * correct when the source is not going to hand back rows that a leftover engine-side filter would then + * discard. + * + *

    PRECONDITION, or this key lies: the engine can only know which conjuncts were taken if the connector + * reported them, i.e. returned {@link ScanNodePropertiesResult#withPushdownTracking}. A connector that + * returns the plain result reads {@code "false"} here even when it did in fact push everything. Report + * tracking, or do not use this key.

    + */ + public static final String SYNTHETIC_ALL_CONJUNCTS_PUSHED = "__all_conjuncts_pushed"; + + private ScanNodePropertyKeys() { + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorMetadataSurfaceTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorMetadataSurfaceTest.java new file mode 100644 index 00000000000000..ed2a28985a0144 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorMetadataSurfaceTest.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.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.TreeSet; + +/** + * Freezes the public method surface of {@link ConnectorMetadata} and pins the minimum implementation set. + * + *

    WHY this matters: every method of {@link ConnectorMetadata} and its six {@code Ops} + * sub-interfaces has a default body, so the compiler forces nothing on an implementor and no existing test + * fails when a method silently disappears, gains a parameter, or is moved between interfaces. That is exactly + * what happened when {@code ConnectorTableOps} was split into per-domain super-interfaces: the split is only + * safe if the resulting method set is IDENTICAL, and "identical" was previously unverifiable. This test makes + * the surface a reviewed artifact — {@code src/test/resources/connector-metadata-methods.txt} is the recorded + * set, and any change to it has to be made deliberately.

    + * + *

    When this test goes red on purpose: a batch that intentionally deletes or adds SPI methods must + * regenerate the baseline in the same commit. That is the point — it is a speed bump on the public connector + * surface, not an obstacle. Regenerate by running this test and copying the "actual" set from the failure + * message.

    + */ +public class ConnectorMetadataSurfaceTest { + + private static final String BASELINE_RESOURCE = "/connector-metadata-methods.txt"; + + /** The six domain interfaces {@link ConnectorTableOps} aggregates. Each states its own minimum set. */ + private static final List> TABLE_DOMAINS = Arrays.asList( + ConnectorTableMetadataOps.class, + ConnectorViewOps.class, + ConnectorTableDdlOps.class, + ConnectorColumnEvolutionOps.class, + ConnectorSnapshotRefOps.class, + ConnectorPartitionListingOps.class); + + /** + * The methods every connector must override, no matter which capabilities it declares. All eight shipped + * connectors override the first four; seven of eight override the fifth. Promoting a sixth method into + * this set is a real decision about what "a working connector" means, so it must be made here first. + */ + private static final List UNCONDITIONAL = Arrays.asList( + "buildTableDescriptor", + "getColumnHandles", + "getTableHandle", + "getTableSchema", + "listTableNames"); + + @Test + public void methodSurfaceMatchesRecordedBaseline() throws IOException { + TreeSet actual = renderSurface(ConnectorMetadata.class); + TreeSet expected = readBaseline(); + + TreeSet missing = new TreeSet<>(expected); + missing.removeAll(actual); + TreeSet added = new TreeSet<>(actual); + added.removeAll(expected); + + Assertions.assertTrue(missing.isEmpty() && added.isEmpty(), + "ConnectorMetadata's method surface changed.\n" + + " gone from the baseline (deleted, renamed, or signature changed): " + missing + "\n" + + " new since the baseline: " + added + "\n" + + "If the change is intended, update src/test/resources" + + BASELINE_RESOURCE + " in the same commit. Full actual surface:\n" + + String.join("\n", actual)); + } + + @Test + public void everyMustImplementMarkerSitsOnItsOwnDomain() { + List misplaced = new ArrayList<>(); + for (Method m : ConnectorMetadata.class.getMethods()) { + if (m.getAnnotation(ConnectorMustImplement.class) == null) { + continue; + } + if (!TABLE_DOMAINS.contains(m.getDeclaringClass())) { + misplaced.add(m.getDeclaringClass().getSimpleName() + "#" + m.getName()); + } + } + Assertions.assertEquals(Collections.emptyList(), misplaced, + "@ConnectorMustImplement must be declared on one of the six table domain interfaces, so that " + + "'which methods are required' stays attached to the domain that documents why. " + + "Markers found elsewhere: " + misplaced); + } + + @Test + public void everyDomainDeclaresItsRequiredMethods() { + for (Class domain : TABLE_DOMAINS) { + boolean marked = false; + for (Method m : domain.getDeclaredMethods()) { + if (m.getAnnotation(ConnectorMustImplement.class) != null) { + marked = true; + break; + } + } + Assertions.assertTrue(marked, domain.getSimpleName() + + " declares no @ConnectorMustImplement method. Every domain must state what makes it " + + "mandatory — either unconditionally or under a stated precondition — otherwise a " + + "connector author has no way to tell 'nothing is required here' from 'nobody wrote it " + + "down'."); + } + } + + @Test + public void unconditionallyRequiredMethodsAreExactlyTheKnownFive() { + TreeSet unconditional = new TreeSet<>(); + for (Method m : ConnectorMetadata.class.getMethods()) { + ConnectorMustImplement marker = m.getAnnotation(ConnectorMustImplement.class); + if (marker != null && marker.when().isEmpty()) { + unconditional.add(m.getName()); + } + } + Assertions.assertEquals(new TreeSet<>(UNCONDITIONAL), unconditional, + "The set of unconditionally required SPI methods changed. Each entry claims 'a connector that " + + "does not override this cannot serve a single query'; adding one silently makes every " + + "existing connector retroactively incomplete. Update UNCONDITIONAL here, with the " + + "override evidence, in the same commit as the annotation change."); + } + + private static TreeSet renderSurface(Class iface) { + TreeSet rendered = new TreeSet<>(); + for (Method m : iface.getMethods()) { + if (m.isSynthetic()) { + continue; + } + StringBuilder sb = new StringBuilder(m.getName()).append('('); + Class[] params = m.getParameterTypes(); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(params[i].getTypeName()); + } + rendered.add(sb.append(')').toString()); + } + return rendered; + } + + private static TreeSet readBaseline() throws IOException { + TreeSet baseline = new TreeSet<>(); + try (InputStream in = ConnectorMetadataSurfaceTest.class.getResourceAsStream(BASELINE_RESOURCE)) { + Assertions.assertNotNull(in, "missing test resource " + BASELINE_RESOURCE); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + String line; + while ((line = reader.readLine()) != null) { + if (!line.trim().isEmpty()) { + baseline.add(line.trim()); + } + } + } + return baseline; + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorScanProviderSelectionTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorScanProviderSelectionTest.java index fe0c66e23dc43c..41d2b1c571c530 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorScanProviderSelectionTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorScanProviderSelectionTest.java @@ -17,18 +17,16 @@ package org.apache.doris.connector.api; -import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.util.Collections; import java.util.List; -import java.util.Optional; /** * Pins the per-table scan-provider selection seam @@ -53,8 +51,7 @@ private NamedProvider(String name) { } @Override - public List planScan(ConnectorSession session, ConnectorTableHandle handle, - List columns, Optional filter) { + public List planScan(ConnectorSession session, ConnectorScanRequest request) { return Collections.emptyList(); } diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorTypeTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorTypeTest.java new file mode 100644 index 00000000000000..8520415e63b5b7 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorTypeTest.java @@ -0,0 +1,178 @@ +// 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 org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Pins the complex-type shape contract enforced by the {@link ConnectorType} constructor. + * + *

    WHY this matters: a complex type is described by PARALLEL lists — one of child types, one + * of STRUCT field names, plus optional per-child metadata. Nothing downstream can detect that they + * disagree: fe-core's converter fills a missing STRUCT field name with {@code "col" + index} and turns + * a childless ARRAY/MAP into {@code ARRAY}/{@code MAP}, all silently. The connector + * that got it wrong compiles, the table loads, DESCRIBE prints something — and the user only finds out + * when a query that names a real sub-field is rejected as "field name not found", by which point the + * error site is nowhere near the type mapping that caused it. So the invariant is enforced where the + * mistake is made: at construction. + */ +public class ConnectorTypeTest { + + private static final ConnectorType INT = ConnectorType.of("INT"); + private static final ConnectorType STR = ConnectorType.of("STRING"); + + private static ConnectorType struct(List children, List names) { + return new ConnectorType("STRUCT", -1, -1, children, names); + } + + // ---------- STRUCT: field names are mandatory and parallel ---------- + + @Test + public void testStructWithoutFieldNamesRejected() { + // Exactly what the trino ROW mapping used to build. + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("STRUCT", -1, -1, Arrays.asList(INT, STR))); + // Both counts belong in the message: "they disagree" without the numbers does not locate the bug. + Assertions.assertTrue(e.getMessage().contains("(0)") && e.getMessage().contains("(2)"), e.getMessage()); + } + + @Test + public void testStructWithTooFewFieldNamesRejected() { + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> struct(Arrays.asList(INT, STR), Collections.singletonList("a"))); + Assertions.assertTrue(e.getMessage().contains("(1)") && e.getMessage().contains("(2)"), e.getMessage()); + } + + @Test + public void testStructWithTooManyFieldNamesRejected() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> struct(Collections.singletonList(INT), Arrays.asList("a", "b"))); + } + + @Test + public void testStructWithNoChildrenRejected() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> struct(Collections.emptyList(), Collections.emptyList())); + } + + @Test + public void testStructWithNullFieldNameRejected() { + // A null name is unresolvable in exactly the same way a missing one is. + Assertions.assertThrows(IllegalArgumentException.class, + () -> struct(Arrays.asList(INT, STR), Arrays.asList("a", null))); + } + + @Test + public void testLowercaseStructTagStillValidated() { + // The tag is compared case-insensitively so a spelling variant cannot dodge the check. + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("struct", -1, -1, Arrays.asList(INT, STR))); + } + + // ---------- ARRAY / MAP: fixed arity ---------- + + @Test + public void testArrayArityEnforced() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("ARRAY", -1, -1, Collections.emptyList())); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("ARRAY", -1, -1, Arrays.asList(INT, STR))); + } + + @Test + public void testMapArityEnforced() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("MAP", -1, -1, Collections.singletonList(INT))); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("MAP", -1, -1, Arrays.asList(INT, STR, INT))); + } + + // ---------- optional per-child lists ---------- + + @Test + public void testOptionalListLongerThanChildrenRejected() { + // An entry with no child to belong to cannot be interpreted at all. + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("STRUCT", -1, -1, Collections.singletonList(INT), + Collections.singletonList("a"), Arrays.asList(true, false), + Collections.emptyList(), Collections.emptyList())); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorType("ARRAY", -1, -1, Collections.singletonList(INT), + Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + Arrays.asList(1, 2))); + } + + @Test + public void testOptionalListShorterThanChildrenStillLegal() { + // Deliberately NOT rejected: these four are read index-tolerantly ("not carried for that index" + // -> default), which is the documented behavior every legacy factory relies on. Tightening this + // to exact-length would hard-fail a supported state. + ConnectorType ct = new ConnectorType("STRUCT", -1, -1, Arrays.asList(INT, STR), + Arrays.asList("a", "b"), Collections.singletonList(false), + Collections.emptyList(), Collections.emptyList()); + Assertions.assertFalse(ct.isChildNullable(0)); + Assertions.assertTrue(ct.isChildNullable(1)); + Assertions.assertNull(ct.getChildComment(0)); + Assertions.assertEquals(-1, ct.getChildFieldId(0)); + } + + // ---------- every legal construction path must stay legal ---------- + + @Test + public void testFactoriesRemainValid() { + Assertions.assertEquals(1, ConnectorType.arrayOf(INT).getChildren().size()); + Assertions.assertEquals(1, ConnectorType.arrayOf(INT, false).getChildren().size()); + Assertions.assertEquals(2, ConnectorType.mapOf(STR, INT).getChildren().size()); + Assertions.assertEquals(2, ConnectorType.mapOf(STR, INT, false).getChildren().size()); + + List names = Arrays.asList("a", "b"); + List types = Arrays.asList(INT, STR); + Assertions.assertEquals(names, ConnectorType.structOf(names, types).getFieldNames()); + Assertions.assertEquals(names, ConnectorType.structOf(names, types, + Arrays.asList(true, false), Arrays.asList("c1", "c2")).getFieldNames()); + Assertions.assertEquals(names, ConnectorType.structOf(names, types, + Arrays.asList(true, false), Arrays.asList("c1", "c2"), + Arrays.asList(true, true)).getFieldNames()); + } + + @Test + public void testWithChildrenFieldIdsRemainsValid() { + // The iceberg usage: one id per child on ARRAY (1), MAP (2) and STRUCT (N). + Assertions.assertEquals(7, ConnectorType.arrayOf(INT) + .withChildrenFieldIds(Collections.singletonList(7)).getChildFieldId(0)); + Assertions.assertEquals(9, ConnectorType.mapOf(STR, INT) + .withChildrenFieldIds(Arrays.asList(8, 9)).getChildFieldId(1)); + Assertions.assertEquals(3, ConnectorType.structOf(Arrays.asList("a", "b"), Arrays.asList(INT, STR)) + .withChildrenFieldIds(Arrays.asList(2, 3)).getChildFieldId(1)); + } + + @Test + public void testNonComplexTypesUnaffected() { + // typeName is a free-form string with no vocabulary, so an unrecognized tag must not be + // second-guessed - we cannot conclude "then it has no children". + Assertions.assertEquals("JSONB", ConnectorType.of("JSONB").getTypeName()); + Assertions.assertEquals(10, ConnectorType.of("DECIMALV3", 10, 2).getPrecision()); + Assertions.assertEquals("SOMETHING", + new ConnectorType("SOMETHING", -1, -1, Arrays.asList(INT, STR)).getTypeName()); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/ConnectorWriteHandleTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/ConnectorWriteHandleTest.java index b837cede0fd916..b214c8919f003c 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/ConnectorWriteHandleTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/handle/ConnectorWriteHandleTest.java @@ -56,7 +56,7 @@ public boolean isOverwrite() { } @Override - public Map getWriteContext() { + public Map getStaticPartitionSpec() { return Collections.emptyMap(); } } diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshotTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshotTest.java index bf12cfa641c1dc..419cf378ddb099 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshotTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshotTest.java @@ -59,24 +59,20 @@ public void existingFieldsRoundTripUnaffectedBySchemaId() { // exactly as before so no existing consumer regresses. ConnectorMvccSnapshot snapshot = ConnectorMvccSnapshot.builder() .snapshotId(11L) - .timestampMillis(1700000000000L) - .description("d") .property("scan.snapshot-id", "11") .schemaId(2L) .build(); Assertions.assertEquals(11L, snapshot.getSnapshotId()); - Assertions.assertEquals(1700000000000L, snapshot.getTimestampMillis()); - Assertions.assertEquals("d", snapshot.getDescription()); Assertions.assertEquals("11", snapshot.getProperties().get("scan.snapshot-id")); Assertions.assertEquals(2L, snapshot.getSchemaId()); } @Test - public void equalsAndHashCodeCoverAllSixFields() { + public void equalsAndHashCodeCoverAllFourFields() { // WHY: ConnectorMvccSnapshot joins its value-object family (ConnectorMvccPartition, // ConnectorTimeTravelSpec, ConnectorTableFreshness, ...) which all define value equality. - // Every one of the 6 fields must participate, so two snapshots differing in ANY single + // Every one of the 4 fields must participate, so two snapshots differing in ANY single // field compare unequal. MUTATION: dropping a field from equals()/hashCode() makes the // matching assertNotEquals below fail (the differing pair would wrongly compare equal). ConnectorMvccSnapshot base = fullSnapshot(); @@ -85,10 +81,8 @@ public void equalsAndHashCodeCoverAllSixFields() { Assertions.assertEquals(base.hashCode(), same.hashCode()); Assertions.assertNotEquals(base, fullSnapshotBuilder().snapshotId(999L).build()); - Assertions.assertNotEquals(base, fullSnapshotBuilder().timestampMillis(999L).build()); Assertions.assertNotEquals(base, fullSnapshotBuilder().schemaId(999L).build()); Assertions.assertNotEquals(base, fullSnapshotBuilder().lastModifiedFreshness(true).build()); - Assertions.assertNotEquals(base, fullSnapshotBuilder().description("other").build()); Assertions.assertNotEquals(base, fullSnapshotBuilder().property("k2", "v2").build()); } @@ -97,10 +91,8 @@ public void toStringExposesEveryField() { // WHY: toString feeds EXPLAIN/log diagnostics; a field silently omitted hides drift. String s = fullSnapshot().toString(); Assertions.assertTrue(s.contains("snapshotId=11"), s); - Assertions.assertTrue(s.contains("timestampMillis=1700000000000"), s); Assertions.assertTrue(s.contains("schemaId=2"), s); Assertions.assertTrue(s.contains("lastModifiedFreshness=false"), s); - Assertions.assertTrue(s.contains("description='d'"), s); Assertions.assertTrue(s.contains("k=v"), s); } @@ -108,8 +100,6 @@ private static ConnectorMvccSnapshot.Builder fullSnapshotBuilder() { // lastModifiedFreshness defaults false; each call returns a fresh, fully-populated builder. return ConnectorMvccSnapshot.builder() .snapshotId(11L) - .timestampMillis(1700000000000L) - .description("d") .schemaId(2L) .property("k", "v"); } diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOpsDefaultsTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOpsDefaultsTest.java index af45adba1980c5..d001c51f61b178 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOpsDefaultsTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOpsDefaultsTest.java @@ -66,6 +66,15 @@ public ConnectorProcedureResult execute(ConnectorSession session, ConnectorTable } } + @Test + public void getRestPassthroughDefaultsToNull() { + // The HTTP passthrough is one source's escape hatch. It used to be a method on Connector itself whose + // default threw, i.e. every connector inherited an entry point it could not serve; now absence is + // declared the same way every other optional subsystem is, and the endpoints probe for null. + Assertions.assertNull(new BareConnector().getRestPassthrough(), + "a connector that fronts no HTTP source must inherit a null getRestPassthrough()"); + } + @Test public void getProcedureOpsDefaultsToNull() { Assertions.assertNull(new BareConnector().getProcedureOps(), @@ -167,6 +176,30 @@ public void planRewriteDefaultsToUnsupported() { Collections.emptyMap(), null, Collections.emptyList())); } + @Test + public void buildRewriteResultDefaultsToUnsupported() { + ConnectorProcedureOps ops = new BareProcedureOps(); + // Same reasoning as planRewrite, and the failure mode is worse if it is softened: a default that + // returned an empty result would turn a misrouted distributed procedure into an empty result set the + // user cannot distinguish from "nothing to do". MUTATION: defaulting to an empty + // ConnectorProcedureResult -> no throw -> red. + Assertions.assertThrows(UnsupportedOperationException.class, + () -> ops.buildRewriteResult("rewrite_data_files", + new ConnectorRewriteStatistics(0, 0, 0L, 0))); + } + + @Test + public void rewriteStatisticsCarriesEachFieldVerbatim() { + // Four DISTINCT values: the engine fills this from three group sums plus one post-commit count, and the + // connector reads it back by name to render its row. A transposed getter is exactly the failure this + // object exists to make visible. MUTATION: any getter returning a neighbouring field -> red. + ConnectorRewriteStatistics stats = new ConnectorRewriteStatistics(3, 2, 4096L, 1); + Assertions.assertEquals(3, stats.getDataFileCount()); + Assertions.assertEquals(2, stats.getAddedDataFileCount()); + Assertions.assertEquals(4096L, stats.getTotalSizeBytes()); + Assertions.assertEquals(1, stats.getDeleteFileCount()); + } + @Test public void rewriteGroupExposesPathsAndStats() { ConnectorRewriteGroup g = new ConnectorRewriteGroup( diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/pushdown/ConnectorOrTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/pushdown/ConnectorOrTest.java new file mode 100644 index 00000000000000..ef0c00ec195299 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/pushdown/ConnectorOrTest.java @@ -0,0 +1,90 @@ +// 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.pushdown; + +import org.apache.doris.connector.api.ConnectorType; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Pins the construction contract of {@link ConnectorOr}. + * + *

    WHY this matters: connectors translate this node arm by arm into their own predicate + * dialect. The class documents itself as "two or more disjuncts", but nothing used to enforce it, + * and a degenerate or mutated node does not fail loudly on the consumer side - it silently produces + * a NARROWER pushed-down predicate, which makes the source skip data the query should have returned. + * Missing rows have no error, no warning and no EXPLAIN signal, so the invariant has to be enforced + * where it is cheap to check: at construction. + */ +public class ConnectorOrTest { + + private static ConnectorExpression arm(String column) { + return new ConnectorColumnRef(column, ConnectorType.of("INT")); + } + + @Test + public void testEmptyDisjunctListRejected() { + IllegalArgumentException e = Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorOr(Collections.emptyList())); + Assertions.assertTrue(e.getMessage().contains("at least two disjuncts"), e.getMessage()); + } + + @Test + public void testSingleDisjunctRejected() { + // A one-armed OR is the caller's logic error (the arm should have been used directly). + // Absorbing it silently is what lets a lost arm reach a connector unnoticed. + Assertions.assertThrows(IllegalArgumentException.class, + () -> new ConnectorOr(Collections.singletonList(arm("a")))); + } + + @Test + public void testNullDisjunctListRejected() { + Assertions.assertThrows(NullPointerException.class, () -> new ConnectorOr(null)); + } + + @Test + public void testTwoOrMoreDisjunctsAccepted() { + Assertions.assertEquals(2, new ConnectorOr(Arrays.asList(arm("a"), arm("b"))) + .getDisjuncts().size()); + Assertions.assertEquals(3, new ConnectorOr(Arrays.asList(arm("a"), arm("b"), arm("c"))) + .getDisjuncts().size()); + } + + @Test + public void testCallerListMutationDoesNotChangeNode() { + // The node used to wrap the caller's list as an unmodifiable VIEW, so a caller that kept + // building its own list afterwards would mutate an already-constructed predicate node. + // ConnectorIn in this same package copies defensively; this pins the two to one behavior. + List arms = new ArrayList<>(Arrays.asList(arm("a"), arm("b"))); + ConnectorOr or = new ConnectorOr(arms); + arms.add(arm("c")); + Assertions.assertEquals(2, or.getDisjuncts().size()); + } + + @Test + public void testDisjunctsAreUnmodifiable() { + ConnectorOr or = new ConnectorOr(Arrays.asList(arm("a"), arm("b"))); + Assertions.assertThrows(UnsupportedOperationException.class, () -> or.getDisjuncts().add(arm("c"))); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorPartitionValuesTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorPartitionValuesTest.java new file mode 100644 index 00000000000000..ec313e68c4ce4f --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorPartitionValuesTest.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.api.scan; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * A guard rail, not a behaviour snapshot: it exists to stop someone from "tidying up" the canonical NULL + * partition NAME once its Java symbol no longer carries a source brand. + * + *

    WHY the literal is frozen: a partition name is persisted, user-visible identity. It is baked into view and + * materialized-view definitions (e.g. a view filtering {@code t_int != "__HIVE_DEFAULT_PARTITION__"}), into + * {@code partition_values()} table-function output, and into the {@code columns_from_path} bytes BE parses; BE + * also hardcodes the same literal on the hive write path ({@code vhive_utils.cpp}). Changing the string orphans + * every object already persisted with it. Renaming the SYMBOL is free; changing the VALUE is not.

    + */ +public class ConnectorPartitionValuesTest { + + @Test + public void nullPartitionNameLiteralIsFrozen() { + Assertions.assertEquals("__HIVE_DEFAULT_PARTITION__", ConnectorPartitionValues.NULL_PARTITION_NAME, + "the canonical NULL partition name is a persisted identity — rename the symbol, never the value"); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderBatchScanTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderBatchScanTest.java index 4bedcf01bf1647..8aa6656b007966 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderBatchScanTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderBatchScanTest.java @@ -18,8 +18,10 @@ package org.apache.doris.connector.api.scan; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.junit.jupiter.api.Assertions; @@ -36,37 +38,28 @@ * *

    Why this matters: these defaults are what keep the change zero-break for the other * connectors (es/jdbc/hive/paimon/hudi/trino). {@code supportsBatchScan} MUST default to false so no - * connector silently enters batch mode without opting in; {@code planScanForPartitionBatch} MUST - * delegate to the 6-arg {@code planScan} with the batch as the required partitions (and forward the - * limit), so a connector whose {@code planScan} is partition-set-scoped — like MaxCompute — gets + * connector silently enters batch mode without opting in; {@code planScanForPartitionBatch} MUST call + * {@code planScan} with the batch as the required partitions AND everything else about the request + * unchanged, so a connector whose {@code planScan} is partition-set-scoped — like MaxCompute — gets * correct per-batch behaviour without overriding it.

    */ public class ConnectorScanPlanProviderBatchScanTest { - /** Records the partition list / limit the default planScanForPartitionBatch forwards. */ + /** Records the request the default planScanForPartitionBatch forwards. */ private static final class RecordingProvider implements ConnectorScanPlanProvider { static final List MARKER = Collections.emptyList(); - List recordedRequiredPartitions; - long recordedLimit = Long.MIN_VALUE; - boolean fourArgCalled; + ConnectorScanRequest recordedRequest; @Override - public List planScan(ConnectorSession session, ConnectorTableHandle handle, - List columns, Optional filter) { - fourArgCalled = true; - return MARKER; - } - - @Override - public List planScan(ConnectorSession session, ConnectorTableHandle handle, - List columns, Optional filter, - long limit, List requiredPartitions) { - this.recordedLimit = limit; - this.recordedRequiredPartitions = requiredPartitions; + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + this.recordedRequest = request; return MARKER; } } + private static final ConnectorTableHandle HANDLE = new ConnectorTableHandle() { + }; + @Test public void testSupportsBatchScanDefaultsFalse() { // Default MUST be false: any connector that does not opt in stays on the synchronous path. @@ -92,21 +85,48 @@ public void testStreamSplitsDefaultThrows() { } @Test - public void testPlanScanForPartitionBatchDelegatesToSixArgPlanScan() { - // Default MUST forward the batch as requiredPartitions and pass the limit through to the - // 6-arg planScan, returning its result. A connector with partition-set-scoped planScan - // (MaxCompute) relies on this to avoid overriding the method. + public void testPlanScanForPartitionBatchRescopesTheRequestToTheBatch() { + // Default MUST call planScan with the batch as the required partitions and EVERY other field of + // the request carried over untouched. A connector with partition-set-scoped planScan (MaxCompute) + // relies on this to avoid overriding the method. MUTATION: a withRequiredPartitions that dropped + // any other field would leave the batched scan planning without the filter or the limit, which is + // exactly the silent capability loss the request object replaced -> red here. RecordingProvider provider = new RecordingProvider(); List batch = Arrays.asList("pt=1", "pt=2"); + List columns = Collections.emptyList(); + ConnectorExpression filter = new ConnectorColumnRef("c1", ConnectorType.of("INT")); + ConnectorScanRequest request = ConnectorScanRequest.builder(HANDLE, columns) + .filter(Optional.of(filter)) + .limit(7L) + .countPushdown(true) + .build(); - List result = - provider.planScanForPartitionBatch(null, null, Collections.emptyList(), - Optional.empty(), -1L, batch); + List result = provider.planScanForPartitionBatch(null, request, batch); Assertions.assertSame(RecordingProvider.MARKER, result); - Assertions.assertSame(batch, provider.recordedRequiredPartitions); - Assertions.assertEquals(-1L, provider.recordedLimit); - // It must route through the 6-arg overload, not collapse to the 4-arg one. - Assertions.assertFalse(provider.fourArgCalled); + ConnectorScanRequest forwarded = provider.recordedRequest; + Assertions.assertEquals(batch, forwarded.getRequiredPartitions()); + Assertions.assertSame(HANDLE, forwarded.getTableHandle()); + Assertions.assertSame(columns, forwarded.getColumns()); + Assertions.assertSame(filter, forwarded.getFilter().orElse(null)); + Assertions.assertEquals(7L, forwarded.getLimit()); + Assertions.assertTrue(forwarded.isCountPushdown()); + } + + @Test + public void testRequestDefaultsAskForNothingSpecial() { + // The fields a connector may ignore must default to "the engine is not asking for anything": + // no filter, no limit, every partition, no COUNT(*) pushdown. A different default would make a + // connector that reads them behave differently depending on which builder setters the caller used. + ConnectorScanRequest request = + ConnectorScanRequest.builder(HANDLE, Collections.emptyList()).build(); + + Assertions.assertFalse(request.getFilter().isPresent()); + Assertions.assertEquals(-1L, request.getLimit()); + Assertions.assertTrue(request.getRequiredPartitions().isEmpty()); + Assertions.assertFalse(request.isCountPushdown()); + // null is accepted for the partition set and means the same as empty: scan everything. + Assertions.assertTrue(ConnectorScanRequest.builder(HANDLE, Collections.emptyList()) + .requiredPartitions(null).build().getRequiredPartitions().isEmpty()); } } diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderCompressTypeTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderCompressTypeTest.java index 1862224b47f69f..29e456bc376853 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderCompressTypeTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderCompressTypeTest.java @@ -18,9 +18,6 @@ package org.apache.doris.connector.api.scan; import org.apache.doris.connector.api.ConnectorSession; -import org.apache.doris.connector.api.handle.ConnectorColumnHandle; -import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.thrift.TFileCompressType; import org.junit.jupiter.api.Assertions; @@ -28,7 +25,6 @@ import java.util.Collections; import java.util.List; -import java.util.Optional; /** * Guards the additive {@code adjustFileCompressType} SPI default on {@link ConnectorScanPlanProvider}. @@ -40,11 +36,10 @@ */ public class ConnectorScanPlanProviderCompressTypeTest { - /** Bare provider: only the abstract 4-arg planScan implemented; everything else inherits SPI defaults. */ + /** Bare provider: only the abstract planScan implemented; everything else inherits SPI defaults. */ private static final class BareProvider implements ConnectorScanPlanProvider { @Override - public List planScan(ConnectorSession session, ConnectorTableHandle handle, - List columns, Optional filter) { + public List planScan(ConnectorSession session, ConnectorScanRequest request) { return Collections.emptyList(); } } diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanRangeWeightDefaultsTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanRangeWeightDefaultsTest.java index 05d625512ba0cc..c522ca6de52296 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanRangeWeightDefaultsTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanRangeWeightDefaultsTest.java @@ -34,11 +34,6 @@ public class ConnectorScanRangeWeightDefaultsTest { @Test public void defaultWeightGettersReturnSentinel() { ConnectorScanRange range = new ConnectorScanRange() { - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Map getProperties() { return Collections.emptyMap(); diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ScanNodePropertiesFacesTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ScanNodePropertiesFacesTest.java new file mode 100644 index 00000000000000..a4f6061b370faa --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ScanNodePropertiesFacesTest.java @@ -0,0 +1,123 @@ +// 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.scan; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorColumnHandle; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.pushdown.ConnectorExpression; + +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.Optional; +import java.util.Set; + +/** + * Guards the relation between the two scan-node-property return faces of {@link ConnectorScanPlanProvider}, + * which the interface javadoc now states as a rule: the engine calls ONLY + * {@code getScanNodePropertiesResult}, whose default implementation delegates to the {@code Map} face + * without conjunct tracking. A connector overrides one face or the other, never both. + * + *

    Why this matters: the two behaviours the javadoc promises are both silent when wrong. A + * connector that overrides only the {@code Map} face must still reach the engine (otherwise its scan + * properties vanish and, for instance, its storage credentials never reach BE); and the delegation must + * NOT claim conjunct tracking, because "tracking with an empty not-pushed set" means "every conjunct was + * pushed exactly, prune them all" — asserting that for a connector which never reported anything would + * drop filters and return extra rows.

    + */ +public class ScanNodePropertiesFacesTest { + + private static final ConnectorTableHandle HANDLE = new ConnectorTableHandle() { + }; + + /** Overrides only the Map face — the shape 5 of the shipped connectors rely on. */ + private static final class MapFaceOnlyProvider implements ConnectorScanPlanProvider { + @Override + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + return Collections.emptyList(); + } + + @Override + public Map getScanNodeProperties(ConnectorSession session, + ConnectorTableHandle handle, List columns, + Optional filter) { + return Collections.singletonMap(ScanNodePropertyKeys.FILE_FORMAT_TYPE, "parquet"); + } + } + + /** Overrides only the result face, reporting fine-grained pushdown — the es shape. */ + private static final class TrackingFaceProvider implements ConnectorScanPlanProvider { + @Override + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + return Collections.emptyList(); + } + + @Override + public ScanNodePropertiesResult getScanNodePropertiesResult(ConnectorSession session, + ConnectorTableHandle handle, List columns, + Optional filter) { + return ScanNodePropertiesResult.withPushdownTracking( + Collections.singletonMap(ScanNodePropertyKeys.FILE_FORMAT_TYPE, "es_http"), + Collections.singleton(1)); + } + } + + @Test + public void mapFaceReachesEngineWithoutClaimingConjunctTracking() { + ScanNodePropertiesResult result = new MapFaceOnlyProvider().getScanNodePropertiesResult( + null, HANDLE, Collections.emptyList(), Optional.empty()); + + // The map a Map-face-only connector returns is what the engine consumes. MUTATION: making the + // default return an empty map -> red (and in production the connector's location.* credentials + // would never reach BE). + Assertions.assertEquals("parquet", result.getProperties().get(ScanNodePropertyKeys.FILE_FORMAT_TYPE)); + + // Not "everything was pushed": the engine must keep every conjunct. MUTATION: switching the + // default to withPushdownTracking(props, emptySet()) -> red, and in production every conjunct + // would be pruned unevaluated -> extra rows. + Assertions.assertFalse(result.hasConjunctTracking()); + Assertions.assertTrue(result.getNotPushedConjunctIndices().isEmpty()); + } + + @Test + public void resultFaceCarriesTheReportedNotPushedIndices() { + ScanNodePropertiesResult result = new TrackingFaceProvider().getScanNodePropertiesResult( + null, HANDLE, Collections.emptyList(), Optional.empty()); + + Assertions.assertTrue(result.hasConjunctTracking()); + Assertions.assertEquals(Collections.singleton(1), result.getNotPushedConjunctIndices()); + Assertions.assertEquals("es_http", result.getProperties().get(ScanNodePropertyKeys.FILE_FORMAT_TYPE)); + } + + @Test + public void trackingWithAnEmptySetMeansPruneEverything() { + Set nothingKept = Collections.emptySet(); + ScanNodePropertiesResult result = + ScanNodePropertiesResult.withPushdownTracking(Collections.emptyMap(), nothingKept); + + // The two factories are the only way to pick this bit now; it used to be encoded by which + // constructor overload the caller happened to choose. + Assertions.assertTrue(result.hasConjunctTracking()); + Assertions.assertTrue(result.getNotPushedConjunctIndices().isEmpty()); + Assertions.assertFalse(ScanNodePropertiesResult.of(Collections.emptyMap()).hasConjunctTracking()); + } +} diff --git a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWritePlanProviderDefaultsTest.java b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWritePlanProviderDefaultsTest.java index 0e9cb532985bae..895955dfcc83b4 100644 --- a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWritePlanProviderDefaultsTest.java +++ b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/write/ConnectorWritePlanProviderDefaultsTest.java @@ -28,9 +28,10 @@ * Pins the {@link ConnectorWritePlanProvider#getWriteSortColumns} default. * *

    WHY this matters: the generic translator asks every write-capable connector for its - * write-sort columns. The default MUST be an empty list so connectors that declare no write sort - * (jdbc / maxcompute) produce no {@code TSortInfo} and keep their byte-identical unsorted sink output — - * only iceberg (with a {@code WRITE ORDERED BY}) overrides it.

    + * write-sort columns. The default MUST be {@code null} — NOT an empty list, which would signal a + * present-but-empty sort order — so connectors that declare no write sort (jdbc / maxcompute) produce no + * {@code TSortInfo} and keep their byte-identical unsorted sink output; only iceberg (with a + * {@code WRITE ORDERED BY}) overrides it.

    */ public class ConnectorWritePlanProviderDefaultsTest { diff --git a/fe/fe-connector/fe-connector-api/src/test/resources/connector-metadata-methods.txt b/fe/fe-connector/fe-connector-api/src/test/resources/connector-metadata-methods.txt new file mode 100644 index 00000000000000..4d6c43156f3ea2 --- /dev/null +++ b/fe/fe-connector/fe-connector-api/src/test/resources/connector-metadata-methods.txt @@ -0,0 +1,72 @@ +addColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ConnectorColumn,org.apache.doris.connector.api.ddl.ConnectorColumnPosition) +addColumns(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.List) +addNestedColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.ConnectorColumnPath,org.apache.doris.connector.api.ConnectorColumn,org.apache.doris.connector.api.ddl.ConnectorColumnPosition) +addPartitionField(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.PartitionFieldChange) +applyFilter(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.pushdown.ConnectorFilterConstraint) +applyProjection(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.List) +applyRewriteFileScope(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.Set) +applySnapshot(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot) +applyTopnLazyMaterialization(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +beginQuerySnapshot(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +beginTransaction(org.apache.doris.connector.api.ConnectorSession) +beginTransaction(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +buildTableDescriptor(org.apache.doris.connector.api.ConnectorSession,long,java.lang.String,java.lang.String,java.lang.String,int,long) +close() +createDatabase(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.util.Map) +createOrReplaceBranch(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.BranchChange) +createOrReplaceTag(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.TagChange) +createTable(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest) +databaseExists(org.apache.doris.connector.api.ConnectorSession,java.lang.String) +dropBranch(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.DropRefChange) +dropColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.lang.String) +dropDatabase(org.apache.doris.connector.api.ConnectorSession,java.lang.String,boolean,boolean) +dropNestedColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.ConnectorColumnPath) +dropPartitionField(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.PartitionFieldChange) +dropTable(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +dropTag(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.DropRefChange) +dropView(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.lang.String) +estimateDataSizeByListingFiles(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +fromRemoteColumnName(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.lang.String,java.lang.String) +fromRemoteDatabaseName(org.apache.doris.connector.api.ConnectorSession,java.lang.String) +fromRemoteTableName(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.lang.String) +getColumnHandles(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +getColumnHandles(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot) +getColumnStatistics(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.lang.String) +getDatabase(org.apache.doris.connector.api.ConnectorSession,java.lang.String) +getMvccPartitionView(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +getPartitionFreshnessMillis(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.lang.String) +getSyntheticScanPredicates(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot) +getSysTableHandle(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.lang.String) +getTableComment(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.lang.String) +getTableFreshness(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +getTableHandle(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.lang.String) +getTableSchema(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +getTableSchema(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot) +getTableStatistics(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +getTableStatistics(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot) +getViewDefinition(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.lang.String) +isPartitionValuesSysTable(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.lang.String) +listDatabaseNames(org.apache.doris.connector.api.ConnectorSession) +listFileSizes(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +listPartitionNames(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +listPartitions(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.Optional) +listSupportedSysTables(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +listTableNames(org.apache.doris.connector.api.ConnectorSession,java.lang.String) +listViewNames(org.apache.doris.connector.api.ConnectorSession,java.lang.String) +modifyColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ConnectorColumn,org.apache.doris.connector.api.ddl.ConnectorColumnPosition) +modifyColumnComment(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.ConnectorColumnPath,java.lang.String) +modifyNestedColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.ConnectorColumnPath,org.apache.doris.connector.api.ConnectorColumn,org.apache.doris.connector.api.ddl.ConnectorColumnPosition) +renameColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.lang.String,java.lang.String) +renameNestedColumn(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.ConnectorColumnPath,java.lang.String) +renameTable(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.lang.String) +renderShowCreateTableDdl(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle) +reorderColumns(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.List) +replacePartitionField(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.ddl.PartitionFieldChange) +resolveTimeTravel(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec) +supportsCastPredicatePushdown(org.apache.doris.connector.api.ConnectorSession) +supportsColumnHandleSnapshotPin(org.apache.doris.connector.api.ConnectorSession) +truncateTable(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.List) +validateRowLevelDmlMode(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,org.apache.doris.connector.api.handle.WriteOperation) +validateStaticPartitionColumns(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.List) +validateWritePartitionNames(org.apache.doris.connector.api.ConnectorSession,org.apache.doris.connector.api.handle.ConnectorTableHandle,java.util.List) +viewExists(org.apache.doris.connector.api.ConnectorSession,java.lang.String,java.lang.String) diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnector.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnector.java index 5a6c77f6fb3d13..059d59d8db1e1e 100644 --- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnector.java +++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnector.java @@ -21,6 +21,7 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorTestResult; +import org.apache.doris.connector.api.rest.ConnectorRestPassthrough; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.spi.ConnectorContext; @@ -34,7 +35,7 @@ * Elasticsearch connector implementation. * Created once per catalog lifecycle. */ -public class EsConnector implements Connector { +public class EsConnector implements Connector, ConnectorRestPassthrough { private static final Logger LOG = LogManager.getLogger(EsConnector.class); @@ -74,6 +75,15 @@ public void close() throws IOException { // OkHttp clients are shared statics; nothing to close } + /** + * ES is the one connector fronting an HTTP source, so it serves the passthrough itself rather than through + * a separate object; {@code ESCatalogAction} composes the ES-shaped path. + */ + @Override + public ConnectorRestPassthrough getRestPassthrough() { + return this; + } + @Override public String executeRestRequest(String path, String body) { return getOrCreateRestClient().executePassthrough(path, body); diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java index 2db6f8d643cda8..ed324ba9000244 100644 --- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java @@ -120,6 +120,21 @@ public Map getColumnHandles( return handles; } + /** + * Elasticsearch accepts CAST-bearing predicates ({@code true}, the SPI default, stated here rather than + * inherited). + * + *

    This is a conscious acceptance of the risk the SPI documents, not a claim of safety: the residual + * predicate is compiled into the ES query DSL ({@code EsScanPlanProvider.buildQueryDsl}) and evaluated by + * Elasticsearch, so a comparison whose literal ES matches differently than Doris coerced it drops rows AT + * THE SOURCE. It stays {@code true} for parity with the legacy {@code EsScanNode}, which built the same + * DSL; unconvertible conjuncts are already reported back as not-pushed and re-evaluated by BE.

    + */ + @Override + public boolean supportsCastPredicatePushdown(ConnectorSession session) { + return true; + } + /** * Validates that required properties are present. * Called during connector creation. diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java index 64937c81ee1182..e8e1b6e82bb213 100644 --- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java +++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorProvider.java @@ -22,6 +22,7 @@ import org.apache.doris.connector.spi.ConnectorProvider; import java.util.Map; +import java.util.Optional; /** * SPI entry point for the Elasticsearch connector. @@ -34,6 +35,13 @@ public String getType() { return "es"; } + @Override + public Optional defaultDatabaseOnUse() { + // Elasticsearch has no database layer; Doris presents a single synthetic one, so switching to an es + // catalog lands the session in it instead of leaving the session with no database. + return Optional.of(EsConnectorMetadata.DEFAULT_DB); + } + @Override public void validateProperties(Map properties) { Map processed = EsConnectorProperties.processCompatible(properties); diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java index 0388312c17e328..64b4dff3b0b655 100644 --- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanPlanProvider.java @@ -24,8 +24,9 @@ import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.api.scan.ScanNodePropertiesResult; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.doris.thrift.TFileScanRangeParams; import com.fasterxml.jackson.core.JsonProcessingException; @@ -73,6 +74,23 @@ public class EsScanPlanProvider implements ConnectorScanPlanProvider { public static final String PROP_DOCVALUE_CONTEXT_JSON = "docvalue_context_json"; public static final String PROP_FIELDS_CONTEXT_JSON = "fields_context_json"; + /** + * BE contract: ES reads this out of {@code es_properties} and stops the search after that many hits + * instead of scrolling the whole result ({@code ESScanReader::KEY_TERMINATE_AFTER}). The literal is the + * wire value and must not change. + */ + private static final String PROP_LIMIT = "limit"; + + /** + * Connector-private: how many rows BE reads per batch, taken from the session while the properties are + * built (populateScanLevelParams gets no session) and read back there. Namespaced so it can never collide + * with an engine-read key. + */ + private static final String PROP_BATCH_SIZE = "es.batch_size"; + + /** Session variable carrying BE's per-batch row count; the engine exports every visible variable. */ + private static final String SESSION_BATCH_SIZE = "batch_size"; + private final EsConnectorRestClient restClient; private final Map properties; @@ -92,17 +110,9 @@ public EsScanPlanProvider(EsConnectorRestClient restClient, } @Override - public ConnectorScanRangeType getScanRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - - @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - EsTableHandle esHandle = (EsTableHandle) handle; + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + List columns = request.getColumns(); + EsTableHandle esHandle = (EsTableHandle) request.getTableHandle(); String indexName = esHandle.getIndexName(); EsMetadataState state = fetchMetadataState(session, esHandle, columns); @@ -152,15 +162,6 @@ public List planScan( return ranges; } - @Override - public Map getScanNodeProperties( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - return buildScanNodeProperties(session, handle, columns, filter).getProperties(); - } - @Override public ScanNodePropertiesResult getScanNodePropertiesResult( ConnectorSession session, @@ -181,7 +182,14 @@ private ScanNodePropertiesResult buildScanNodeProperties( Map nodeProps = new HashMap<>(); // File format type for PluginDrivenScanNode.getFileFormatType() - nodeProps.put("file_format_type", "es_http"); + nodeProps.put(ScanNodePropertyKeys.FILE_FORMAT_TYPE, "es_http"); + + // Carry BE's per-batch row count forward: populateScanLevelParams decides there whether the pushed + // limit is small enough to ask ES to stop early, and it receives no session. + String batchSize = session.getSessionProperties().get(SESSION_BATCH_SIZE); + if (batchSize != null) { + nodeProps.put(PROP_BATCH_SIZE, batchSize); + } // Table/index metadata for EXPLAIN nodeProps.put("_table_name", esHandle.getIndexName()); @@ -217,7 +225,7 @@ private ScanNodePropertiesResult buildScanNodeProperties( // Build not-pushed conjunct indices set for structured reporting Set notPushedSet = new HashSet<>(dslResult.getNotPushedIndices()); - return new ScanNodePropertiesResult(nodeProps, notPushedSet); + return ScanNodePropertiesResult.withPushdownTracking(nodeProps, notPushedSet); } private void serializeFieldContexts(EsMetadataState state, Map nodeProps) { @@ -368,6 +376,17 @@ public void populateScanLevelParams(TFileScanRangeParams params, copyIfPresent(properties, PROP_PASSWORD, esProperties); copyIfPresent(properties, PROP_HTTP_SSL_ENABLED, esProperties); copyIfPresent(properties, PROP_DOC_VALUES_MODE, esProperties); + // Ask ES to stop after N hits instead of scrolling everything. Only correct when the engine has NO + // filtering left to do after the scan (otherwise rows ES returns could still be filtered out, and + // stopping early would lose rows), and only worth it when the limit fits in one BE batch. The engine + // supplies both facts; used to live in the generic scan node, which had to recognize this connector + // by its format string to do it. + long pushdownLimit = parseLongOrDefault(properties.get(ScanNodePropertyKeys.SYNTHETIC_PUSHDOWN_LIMIT), -1L); + long batchSize = parseLongOrDefault(properties.get(PROP_BATCH_SIZE), -1L); + if (pushdownLimit > 0 && batchSize > 0 && pushdownLimit <= batchSize + && allConjunctsPushed(properties)) { + esProperties.put(PROP_LIMIT, String.valueOf(pushdownLimit)); + } params.setEsProperties(esProperties); // Deserialize docvalue_context and fields_context from JSON @@ -406,6 +425,21 @@ private static void copyIfPresent(Map src, } } + private static boolean allConjunctsPushed(Map properties) { + return "true".equals(properties.get(ScanNodePropertyKeys.SYNTHETIC_ALL_CONJUNCTS_PUSHED)); + } + + private static long parseLongOrDefault(String value, long defaultValue) { + if (value == null) { + return defaultValue; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + @Override public void appendExplainInfo(StringBuilder output, String prefix, Map properties) { @@ -442,6 +476,16 @@ public void appendExplainInfo(StringBuilder output, String prefix, .append("(parse error)").append("\n"); } } + // ATTN this deliberately does NOT repeat populateScanLevelParams' "limit fits in one batch" test, so + // with a batch size below the limit EXPLAIN claims an early stop that is not actually requested. That + // mismatch predates the move (the two halves used to live in different files, one with the test and + // one without) and is preserved byte for byte here: the acceptance baseline for this relocation is + // that EXPLAIN text does not change. Fixing it changes user-visible EXPLAIN and needs a live ES + // cluster to verify, so it is tracked separately. + long pushdownLimit = parseLongOrDefault(properties.get(ScanNodePropertyKeys.SYNTHETIC_PUSHDOWN_LIMIT), -1L); + if (pushdownLimit > 0 && allConjunctsPushed(properties)) { + output.append(prefix).append("ES terminate_after: ").append(pushdownLimit).append("\n"); + } } private List collectAllHosts( diff --git a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanRange.java b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanRange.java index 80fcab57729327..55d8c93e9b9983 100644 --- a/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanRange.java +++ b/fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsScanRange.java @@ -18,7 +18,6 @@ package org.apache.doris.connector.es; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TTableFormatFileDesc; @@ -71,11 +70,6 @@ public EsScanRange(String indexName, String mappingType, this.plainHostnames = extractHostnames(this.esHosts); } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Optional getPath() { return Optional.of("es://" + indexName + "/" + shardId); diff --git a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorProviderTest.java b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorProviderTest.java new file mode 100644 index 00000000000000..9c42af9c7899bb --- /dev/null +++ b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsConnectorProviderTest.java @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.es; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +/** What this connector declares to the engine before any catalog of its type is initialized. */ +public class EsConnectorProviderTest { + + @Test + public void switchingToAnEsCatalogLandsInItsSingleDatabase() { + // Elasticsearch has no database layer; Doris presents one synthetic database for it. SWITCH used to + // reach it through a hardcoded "es" type check in the engine plus a second copy of the "default_db" + // literal. The connector now names it, and it must be the very database this connector lists and + // resolves — otherwise SWITCH lands the session in a database that does not exist. + Assertions.assertEquals(Optional.of(EsConnectorMetadata.DEFAULT_DB), + new EsConnectorProvider().defaultDatabaseOnUse()); + } + + @Test + public void anEsCatalogIsNotForceInitializedForEventSync() { + // ES exposes no metastore-event source, so the engine's event driver must never force-initialize an + // idle es catalog just to look for one. + Assertions.assertFalse(new EsConnectorProvider().providesEventSource()); + } +} diff --git a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsNodeInfoAndScanRangeTest.java b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsNodeInfoAndScanRangeTest.java index 05ce8131862b4c..0ed1d92c14bf4e 100644 --- a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsNodeInfoAndScanRangeTest.java +++ b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsNodeInfoAndScanRangeTest.java @@ -17,7 +17,6 @@ package org.apache.doris.connector.es; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -131,12 +130,6 @@ void testScanRangeBasicProperties() { Assertions.assertEquals(2, range.getEsHosts().size()); } - @Test - void testScanRangeType() { - EsScanRange range = new EsScanRange("idx", null, 1, Collections.emptyList()); - Assertions.assertEquals(ConnectorScanRangeType.FILE_SCAN, range.getRangeType()); - } - @Test void testScanRangeGetProperties() { EsScanRange range = new EsScanRange("logs", "_doc", 3, diff --git a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java index 3c9c24150196a5..78b0ab000a9591 100644 --- a/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java +++ b/fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java @@ -21,7 +21,10 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorStatementScope; import org.apache.doris.connector.api.handle.NamedColumnHandle; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.thrift.TFileScanRangeParams; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -162,10 +165,10 @@ void testPlanScanAndScanNodePropertiesShareOneFetch() { EsScanPlanProvider provider = new EsScanPlanProvider(client, minimalProps()); EsTableHandle handle = new EsTableHandle("test_index"); - provider.planScan(EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty()); - provider.getScanNodeProperties(EMPTY_SESSION, handle, Collections.emptyList(), + provider.planScan(EMPTY_SESSION, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); + provider.getScanNodePropertiesResult(EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty()); - // ES-F1: planScan and getScanNodeProperties of one scan node run on the SAME per-scan-node + // ES-F1: planScan and getScanNodePropertiesResult of one scan node run on the SAME per-scan-node // provider instance, so the metadata state (mapping + shard routing + node topology) is // fetched once and shared -- not twice. MUTATION: removing the memoizedState guard makes // each call refetch -> these go back to 2 -> red. @@ -181,10 +184,12 @@ void testPlanScanAndScanNodePropertiesShareOneFetch() { void testDifferentIndexesFetchSeparately() { CountingRestClient client = new CountingRestClient(); EsScanPlanProvider provider = new EsScanPlanProvider(client, minimalProps()); - provider.planScan(EMPTY_SESSION, new EsTableHandle("index_a"), - Collections.emptyList(), java.util.Optional.empty()); - provider.planScan(EMPTY_SESSION, new EsTableHandle("index_b"), - Collections.emptyList(), java.util.Optional.empty()); + provider.planScan(EMPTY_SESSION, + ConnectorScanRequest.builder(new EsTableHandle("index_a"), Collections.emptyList()) + .build()); + provider.planScan(EMPTY_SESSION, + ConnectorScanRequest.builder(new EsTableHandle("index_b"), Collections.emptyList()) + .build()); // The per-scan memo is guarded on the index, so a provider reused for a different index // still refetches -- distinct indexes never share a memo entry. Assertions.assertEquals(2, client.getMappingCount.get(), @@ -201,9 +206,9 @@ void testSeparateProviderInstancesEachFetchForFreshness() { EsTableHandle handle = new EsTableHandle("test_index"); new EsScanPlanProvider(client, minimalProps()) - .planScan(EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty()); + .planScan(EMPTY_SESSION, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); new EsScanPlanProvider(client, minimalProps()) - .planScan(EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty()); + .planScan(EMPTY_SESSION, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); Assertions.assertEquals(2, client.searchShardsCount.get(), "a separate scan node (provider) must refetch shard routing -- memo is per-scan, not cross-query"); @@ -248,7 +253,7 @@ void testMappingSharedAcrossSchemaAndScanPathsWithinStatement() { new EsConnectorMetadata(client, minimalProps()).getTableSchema(session, handle); new EsScanPlanProvider(client, minimalProps()) - .planScan(session, handle, Collections.emptyList(), java.util.Optional.empty()); + .planScan(session, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); Assertions.assertEquals(1, client.getMappingCount.get(), "one index's mapping must be fetched once per statement across the schema and scan paths"); @@ -282,9 +287,9 @@ void testShardRoutingNeverSharedViaScope() { EsTableHandle handle = new EsTableHandle("test_index"); new EsScanPlanProvider(client, minimalProps()) - .planScan(session, handle, Collections.emptyList(), java.util.Optional.empty()); + .planScan(session, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); new EsScanPlanProvider(client, minimalProps()) - .planScan(session, handle, Collections.emptyList(), java.util.Optional.empty()); + .planScan(session, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); Assertions.assertEquals(2, client.searchShardsCount.get(), "shard routing must be fetched per scan, never shared via the statement scope"); @@ -304,10 +309,12 @@ void testDifferentColumnsRefetch() { EsScanPlanProvider provider = new EsScanPlanProvider(client, minimalProps()); EsTableHandle handle = new EsTableHandle("test_index"); - provider.planScan(EMPTY_SESSION, handle, - Collections.singletonList(new NamedColumnHandle("a")), java.util.Optional.empty()); - provider.planScan(EMPTY_SESSION, handle, - Collections.singletonList(new NamedColumnHandle("b")), java.util.Optional.empty()); + provider.planScan(EMPTY_SESSION, + ConnectorScanRequest.builder(handle, Collections.singletonList(new NamedColumnHandle("a"))) + .build()); + provider.planScan(EMPTY_SESSION, + ConnectorScanRequest.builder(handle, Collections.singletonList(new NamedColumnHandle("b"))) + .build()); Assertions.assertEquals(2, client.searchShardsCount.get(), "a different projection must refetch -- the memo is guarded on columns, not just index"); @@ -326,8 +333,12 @@ public long getCatalogId() { return 0; } }); - Assertions.assertTrue(connector.supportedWriteOperations().isEmpty(), - "ES connector should declare no supported write operations"); + Assertions.assertNull(connector.getWritePlanProvider(), + "ES connector should expose no write plan provider, so every write is rejected"); + // The ES-compatible FE HTTP endpoints probe for this capability and answer badRequest when it is + // absent, so ES must expose it (every other connector inherits the null default). + Assertions.assertNotNull(connector.getRestPassthrough(), + "ES fronts an HTTP source, so it must expose the passthrough capability the ES endpoints need"); // Task 6 P2: the structural contract validator must pass for a real connector (positive control). ConnectorContractValidator.validate(connector, "es"); } @@ -338,14 +349,94 @@ void testAppendExplainInfoShowsEsIndex() { EsScanPlanProvider provider = new EsScanPlanProvider(client, minimalProps()); EsTableHandle handle = new EsTableHandle("my_test_index"); - Map props = provider.getScanNodeProperties( - EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty()); + Map props = provider.getScanNodePropertiesResult( + EMPTY_SESSION, handle, Collections.emptyList(), java.util.Optional.empty()).getProperties(); StringBuilder output = new StringBuilder(); provider.appendExplainInfo(output, "", props); Assertions.assertTrue(output.toString().contains("ES index: my_test_index")); } + // ─────────── early-stop (terminate_after), relocated out of the generic scan node ─────────── + // + // WHY these matter: asking ES to stop after N hits is only correct when the engine has NO filtering left + // to do after the scan — otherwise ES stops early on rows a leftover engine-side filter would have thrown + // away, and the query silently returns too few rows. Both facts come from the engine through synthetic + // property keys. Before the relocation, this decision lived in the generic scan node and was reached by + // matching this connector's format string; it had NO unit coverage at all, only an ES-cluster suite. + + private static Map pushdownProps(String limit, String batchSize, String allPushed) { + Map props = new HashMap<>(); + if (limit != null) { + props.put(ScanNodePropertyKeys.SYNTHETIC_PUSHDOWN_LIMIT, limit); + } + if (batchSize != null) { + props.put("es.batch_size", batchSize); + } + if (allPushed != null) { + props.put(ScanNodePropertyKeys.SYNTHETIC_ALL_CONJUNCTS_PUSHED, allPushed); + } + return props; + } + + private static TFileScanRangeParams populateWith(Map props) { + TFileScanRangeParams params = new TFileScanRangeParams(); + new EsScanPlanProvider(new CountingRestClient(), minimalProps()).populateScanLevelParams(params, props); + return params; + } + + @Test + void terminateAfterIsRequestedWhenTheLimitFitsOneBatchAndNothingIsLeftToFilter() { + // "limit" is the BE-side key ES turns into an early stop; the literal is a wire contract. + Assertions.assertEquals("5", + populateWith(pushdownProps("5", "1024", "true")).getEsProperties().get("limit")); + } + + @Test + void terminateAfterIsNotRequestedWhenFilteringRemains() { + // The correctness case: the engine still has conjuncts to apply, so stopping ES early would lose rows. + Assertions.assertFalse( + populateWith(pushdownProps("5", "1024", "false")).getEsProperties().containsKey("limit"), + "with filtering left to do, an early stop can drop rows that survive the remaining filter"); + } + + @Test + void terminateAfterIsNotRequestedWhenTheLimitExceedsOneBatch() { + Assertions.assertFalse( + populateWith(pushdownProps("5000", "1024", "true")).getEsProperties().containsKey("limit")); + } + + @Test + void terminateAfterIsNotRequestedWhenTheEngineSuppliedNothing() { + // A property map with none of the synthetic keys (an older engine, or a direct unit-test call) must be + // treated as "no limit", not as an unbounded one. + Assertions.assertFalse( + populateWith(pushdownProps(null, null, null)).getEsProperties().containsKey("limit")); + } + + @Test + void explainReportsTheEarlyStopVerbatim() { + StringBuilder output = new StringBuilder(); + new EsScanPlanProvider(new CountingRestClient(), minimalProps()) + .appendExplainInfo(output, "", pushdownProps("5", "1024", "true")); + // The exact text is the acceptance baseline: an ES-cluster suite asserts this string, and this + // relocation must not change it by a character. + Assertions.assertTrue(output.toString().contains("ES terminate_after: 5\n"), output.toString()); + } + + @Test + void explainReportsNoEarlyStopWhenFilteringRemainsOrNothingWasPushed() { + StringBuilder withFilter = new StringBuilder(); + new EsScanPlanProvider(new CountingRestClient(), minimalProps()) + .appendExplainInfo(withFilter, "", pushdownProps("5", "1024", "false")); + Assertions.assertFalse(withFilter.toString().contains("ES terminate_after"), withFilter.toString()); + + StringBuilder noKeys = new StringBuilder(); + new EsScanPlanProvider(new CountingRestClient(), minimalProps()) + .appendExplainInfo(noKeys, "", Collections.emptyMap()); + Assertions.assertFalse(noKeys.toString().contains("ES terminate_after"), noKeys.toString()); + } + @Test void testAppendExplainInfoMissingIndex() { EsScanPlanProvider provider = new EsScanPlanProvider(new CountingRestClient(), minimalProps()); @@ -355,4 +446,14 @@ void testAppendExplainInfoMissingIndex() { Assertions.assertFalse(output.toString().contains("ES index:")); } + + @Test + public void beFileCacheAdmissionDoesNotApplyToEs() { + // ES is read over HTTP, never through BE's file readers, so it must stay out of file-cache admission + // governance exactly as it was while a catalog-type allow-list decided this. + Assertions.assertFalse(new EsScanPlanProvider(new CountingRestClient(), minimalProps()) + .supportsFileCache()); + } + + } 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 e41f981668c5cf..e4a5dcee981246 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 @@ -70,12 +70,13 @@ public class HiveConnector implements Connector { // The sibling connector type a flipped hms gateway delegates iceberg-on-HMS tables to. A string literal // (not the iceberg plugin's own type constant, which is child-first and invisible from the hive loader); - // matches the "iceberg" entry in CatalogFactory.SPI_READY_TYPES. + // matches the type name IcebergConnectorProvider registers. private static final String ICEBERG_CONNECTOR_TYPE = "iceberg"; // The sibling connector type a flipped hms gateway delegates hudi-on-HMS tables to. A string literal (hudi // has NO user-facing catalog type — it is served only via createSiblingConnector); matches the "hudi" type - // string HudiConnectorProvider registers. NEVER add "hudi" to CatalogFactory.SPI_READY_TYPES. + // string HudiConnectorProvider registers, which declares isStandaloneCatalogType() == false so that the + // engine can never build a hudi catalog. Sibling lookup does not consult that flag, so this stays valid. private static final String HUDI_CONNECTOR_TYPE = "hudi"; private final Map properties; @@ -319,8 +320,8 @@ public Set getCapabilities() { // Deliberately NOT declared here: // - SUPPORTS_SHOW_CREATE_DDL: the connector must first emit the table location (show.location) and a // generic-vs-hive-specific SHOW CREATE rendering must be decided — its own substep. - // - SUPPORTS_PASSTHROUGH_QUERY / SUPPORTS_PARTITION_STATS: hive exposes no query() TVF, and legacy SHOW - // PARTITIONS lists names only. + // - SUPPORTS_PARTITION_STATS: legacy SHOW PARTITIONS lists names only. (Passthrough SQL is not a + // capability at all: hive simply does not implement ConnectorPassthroughSqlOps.) // - SUPPORTS_TOPN_LAZY_MATERIALIZE: a per-table marker emitted in getTableSchema (orc/parquet only), // never a connector-wide flag. // - SUPPORTS_COLUMN_AUTO_ANALYZE: legacy StatisticsUtil.supportAutoAnalyze admitted HMS tables of dlaType 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 ca93cd870309e6..097f50e8571be0 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 @@ -32,6 +32,7 @@ import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.ddl.BranchChange; import org.apache.doris.connector.api.ddl.ConnectorBucketSpec; +import org.apache.doris.connector.api.ddl.ConnectorColumnPath; import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; import org.apache.doris.connector.api.ddl.ConnectorCreateTableRequest; import org.apache.doris.connector.api.ddl.ConnectorPartitionField; @@ -67,6 +68,7 @@ import org.apache.doris.connector.hms.HmsTableInfo; import org.apache.doris.connector.hms.HmsTypeMapping; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.FileSystem; import org.apache.doris.thrift.THiveTable; import org.apache.doris.thrift.TTableDescriptor; @@ -84,10 +86,10 @@ import java.util.ArrayList; import java.util.Base64; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -117,6 +119,12 @@ public class HiveConnectorMetadata implements ConnectorMetadata { private static final Logger LOG = LogManager.getLogger(HiveConnectorMetadata.class); + /** + * The HMS table parameter iceberg writes its table comment into (mirrored from the iceberg table property + * of the same name on every commit). + */ + private static final String ICEBERG_TABLE_COMMENT_PARAM = "comment"; + // FE-internal schema-control property key: a CSV of the RAW remote partition-column names. The generic // fe-core consumer (PluginDrivenExternalTable.toSchemaCacheValue) reads it to derive which of the emitted // columns are partition columns; it is the same key the paimon/iceberg/maxcompute connectors emit and is @@ -201,7 +209,6 @@ public class HiveConnectorMetadata implements ConnectorMetadata { }; private final HmsClient hmsClient; - private final Map properties; // Carries the fe-core-injected environment (getEnvironment()) with the FE-global CREATE TABLE defaults // (hive_default_file_format / enable_create_hive_bucket_table / doris_version) that the plugin cannot // read from FE Config. The default getEnvironment() is an empty map, so direct-construction tests that @@ -275,7 +282,6 @@ public HiveConnectorMetadata(HmsClient hmsClient, Map properties HiveFileListingCache fileListingCache, ConnectorMetadataCache> partitionViewCache) { this.hmsClient = hmsClient; - this.properties = properties; this.context = context; this.icebergSiblingSupplier = icebergSiblingSupplier; this.hudiSiblingSupplier = hudiSiblingSupplier; @@ -473,18 +479,19 @@ public ConnectorTableSchema getTableSchema( ConnectorSession session, ConnectorTableHandle handle) { if (!(handle instanceof HiveTableHandle)) { // An iceberg/hudi-on-HMS table's schema is built by the embedded sibling connector, but fe-core's - // PluginDrivenExternalTable.hasScanCapability only ever reads the CATALOG connector (this HIVE - // connector), never the sibling — so a per-table scan capability the sibling declares connector-wide + // PluginDrivenExternalTable.hasCapability only ever reads the CATALOG connector (this HIVE + // connector), never the sibling — so a per-table capability the sibling declares connector-wide // (auto-analyze / Top-N lazy / nested-column prune) would be lost for the embedded table. Reflect the - // owning sibling's connector-wide capability set onto the delegated schema as a per-table marker so it - // survives delegation (mirrors Trino table-redirection, where the redirected-to connector's - // capabilities govern the table). Only 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. + // SIBLING_INHERITABLE_CAPABILITIES subset of the owning sibling's connector-wide set onto the + // delegated schema as a per-table marker so it survives delegation (mirrors Trino table-redirection, + // where the redirected-to connector's capabilities govern the table). Only the subset fe-core + // actually resolves per-table is reflected; a catalog-wide capability (view / show-create / mvcc) + // would be discarded by the reader, so emitting it would only record an unhonoured intent. Resolve + // the owner ONCE (getMetadata is not free) and reuse it for the schema build and the capability read. SiblingOwner owner = siblingOwnerResolver.apply(handle); ConnectorTableSchema siblingSchema = memoizedSiblingMetadata(session, owner.connector(), owner.label()) .getTableSchema(session, handle); - return reflectSiblingScanCapabilities(owner.connector(), siblingSchema); + return reflectSiblingCapabilities(owner.connector(), siblingSchema); } HiveTableHandle hiveHandle = (HiveTableHandle) handle; String dbName = hiveHandle.getDbName(); @@ -519,25 +526,21 @@ public ConnectorTableSchema getTableSchema( // HMSExternalTable.supportedHiveTopNLazyTable), which the connector-wide SUPPORTS_TOPN_LAZY_MATERIALIZE // cannot express for a heterogeneous hive catalog; emit it per-table so fe-core enables the optimization // only for eligible tables and never for text/csv/json/view/hudi. - List perTableCapabilities = new ArrayList<>(); + Set perTableCapabilities = EnumSet.noneOf(ConnectorCapability.class); // Legacy StatisticsUtil.supportAutoAnalyze admitted EVERY plain-hive (dlaType==HIVE) table into background // per-column auto-analyze regardless of file format. Emit it per-table for every plain-hive data table (any - // format, view excluded) so fe-core's hasScanCapability admits them WITHOUT a connector-wide flag (which + // format, view excluded) so fe-core's hasCapability admits them WITHOUT a connector-wide flag (which // would also admit hudi-on-HMS, which legacy excluded). This branch is reached only for a HiveTableHandle; // an iceberg-on-HMS table is served by the delegation branch above (which reflects the iceberg sibling's // own auto-analyze capability), and a hudi-on-HMS table's connector declares neither. if (supportsHiveColumnAutoAnalyze(tableInfo)) { - perTableCapabilities.add(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE.name()); + perTableCapabilities.add(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE); } if (supportsHiveSampleAnalyze(tableInfo)) { - perTableCapabilities.add(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE.name()); + perTableCapabilities.add(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE); } if (supportsHiveTopNLazyMaterialize(tableInfo)) { - perTableCapabilities.add(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE.name()); - } - if (!perTableCapabilities.isEmpty()) { - tableProperties.put(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, - String.join(",", perTableCapabilities)); + perTableCapabilities.add(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE); } // Distribution (bucketing) columns for the flipped table's getDistributionColumnNames() — legacy @@ -549,47 +552,51 @@ public ConnectorTableSchema getTableSchema( tableProperties.put(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY, String.join(",", bucketCols)); } - return new ConnectorTableSchema(tableName, allColumns, formatType, tableProperties); + return new ConnectorTableSchema(tableName, allColumns, formatType, tableProperties, + perTableCapabilities); } /** - * Reflects the owning sibling connector's connector-wide capability set onto a delegated (iceberg/hudi-on-HMS) - * table's schema as a per-table {@link ConnectorTableSchema#PER_TABLE_CAPABILITIES_KEY} marker, merged with any - * marker the sibling already emitted. fe-core's {@code PluginDrivenExternalTable.hasScanCapability} resolves a - * per-table scan capability from the CATALOG (hive) connector-wide set OR this marker and NEVER consults the - * sibling connector directly, so without this reflection an iceberg-on-HMS table would silently lose every scan - * capability the iceberg sibling declares connector-wide (auto-analyze / Top-N lazy / nested-column prune). - * Returns the sibling schema unchanged when the sibling declares no capabilities (e.g. a hudi sibling that - * declares none). Only per-table-refinable capabilities are ever consulted from the marker, so reflecting the - * whole set (including non-scan capabilities) is inert for the rest. + * The capabilities a delegated (iceberg/hudi-on-HMS) table INHERITS from its owning sibling connector — + * exactly the set fe-core resolves per-table ({@code PluginDrivenExternalTable.hasCapability}). Every + * other capability is resolved catalog-wide, so reflecting it would record an intent the engine never + * honours. Listing the subset here (rather than reflecting whatever the sibling happens to declare) is what + * keeps a delegated table's behaviour independent of the sibling's declaration: an iceberg-on-HMS table's + * SHOW CREATE TABLE / view / metadata-preload behaviour cannot start differing from a plain-hive table's + * without an edit to this constant. Listed by capability rather than by "what iceberg declares today" so a + * sibling later declaring sampled analyze inherits it exactly like the others. */ - private ConnectorTableSchema reflectSiblingScanCapabilities(Connector owner, ConnectorTableSchema siblingSchema) { - Set ownerCaps = owner.getCapabilities(); - if (ownerCaps.isEmpty()) { - return siblingSchema; - } - LinkedHashSet caps = new LinkedHashSet<>(); - String existing = siblingSchema.getProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY); - if (existing != null && !existing.isEmpty()) { - for (String name : existing.split(",")) { - String trimmed = name.trim(); - if (!trimmed.isEmpty()) { - caps.add(trimmed); - } + private static final Set SIBLING_INHERITABLE_CAPABILITIES = Collections.unmodifiableSet( + EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE, + ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE, + ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE, + ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE, + ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE)); + + /** + * Reflects the {@link #SIBLING_INHERITABLE_CAPABILITIES} subset of the owning sibling connector's + * connector-wide capability set onto a delegated (iceberg/hudi-on-HMS) table's schema as per-table + * capabilities, merged with whatever the sibling already declared for that table. fe-core's + * {@code PluginDrivenExternalTable.hasCapability} resolves a table-scoped capability from the CATALOG + * (hive) connector-wide set OR the table's own set, and NEVER consults the sibling connector directly, so + * without this reflection an iceberg-on-HMS table would silently lose every such capability the iceberg + * sibling declares connector-wide (auto-analyze / Top-N lazy / nested-column prune / nested column DDL). + * Returns the sibling schema unchanged when nothing is inherited and the sibling declared nothing itself + * (e.g. a hudi sibling, which declares no capabilities at all). + */ + private ConnectorTableSchema reflectSiblingCapabilities(Connector owner, ConnectorTableSchema siblingSchema) { + Set inherited = EnumSet.noneOf(ConnectorCapability.class); + for (ConnectorCapability cap : owner.getCapabilities()) { + if (SIBLING_INHERITABLE_CAPABILITIES.contains(cap)) { + inherited.add(cap); } } - for (ConnectorCapability cap : ownerCaps) { - caps.add(cap.name()); + if (inherited.isEmpty()) { + return siblingSchema; } - Map props = new HashMap<>(siblingSchema.getProperties()); - props.put(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, String.join(",", caps)); + inherited.addAll(siblingSchema.getTableCapabilities()); return new ConnectorTableSchema(siblingSchema.getTableName(), siblingSchema.getColumns(), - siblingSchema.getTableFormatType(), props); - } - - @Override - public Map getProperties() { - return properties; + siblingSchema.getTableFormatType(), siblingSchema.getProperties(), inherited); } // ========== ConnectorTableOps: Column Handles ========== @@ -711,6 +718,41 @@ public void dropView(ConnectorSession session, String dbName, String viewName) { } } + /** + * Returns the table comment. Overridden here for one reason: an iceberg-on-HMS table would otherwise report + * no comment at all. + * + *

    Every other foreign-table operation is diverted to the embedded iceberg sibling by the concrete handle + * type, but this method addresses a table by NAME and never receives a handle, so there is nothing to + * discriminate on. It is answered directly instead: iceberg's HMS catalog mirrors the table's iceberg + * properties into the HMS table parameters on every commit, {@code comment} included — the same + * parameter {@code HiveShowCreateTableRenderer} lifts into the {@code COMMENT} clause — so the single + * (cached) {@code getTable} this connector already performs carries it, with no sibling build and no + * iceberg metadata load. Without this, the same table shows its comment through a dedicated iceberg catalog + * and shows nothing through an HMS gateway catalog, in {@code SHOW CREATE TABLE}, + * {@code information_schema.tables.TABLE_COMMENT} and {@code SHOW TABLE STATUS}.

    + * + *

    Deliberately restricted to iceberg tables: a plain hive table keeps the empty comment that legacy + * {@code HMSExternalTable.getComment} returned, so nothing about plain hive changes. A missing or + * unreadable table degrades to the empty comment rather than failing the caller, matching + * {@link #viewExists} — this is an opportunistic display value, not a user-requested operation.

    + */ + @Override + public String getTableComment(ConnectorSession session, String dbName, String tableName) { + try { + HmsTableInfo tableInfo = hmsClient.getTable(dbName, tableName); + if (HiveTableFormatDetector.detect(tableInfo) != HiveTableType.ICEBERG) { + return ""; + } + Map params = tableInfo.getParameters(); + String comment = params == null ? null : params.get(ICEBERG_TABLE_COMMENT_PARAM); + return comment == null ? "" : comment; + } catch (HmsClientException e) { + LOG.debug("Table comment lookup: '{}.{}' not readable: {}", dbName, tableName, e.getMessage()); + return ""; + } + } + // listViewNames is intentionally NOT overridden: hive's listTableNames (HMS get_all_tables) already // includes views, and PluginDrivenExternalCatalog.listTableNamesFromRemote merges listViewNames into // SHOW TABLES with a plain addAll (no dedup). Returning view names here would DOUBLE-list every hive view; @@ -907,7 +949,7 @@ public long estimateDataSizeByListingFiles(ConnectorSession session, ConnectorTa // calls are cheap.) return estimateDataSize(hiveHandle, STATS_PARTITION_SAMPLE_SIZE, (location, values) -> sumCachedFileSizes( - hiveHandle, location, values, context.getFileSystem(session))); + hiveHandle, location, values, storage().getFileSystem(session))); } finally { Thread.currentThread().setContextClassLoader(previous); } @@ -939,7 +981,7 @@ public List listFileSizes(ConnectorSession session, ConnectorTableHandle h ClassLoader previous = Thread.currentThread().getContextClassLoader(); try { Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); - FileSystem fs = context.getFileSystem(session); + FileSystem fs = storage().getFileSystem(session); List sizes = new ArrayList<>(); for (PartitionRef ref : resolvePartitionRefs(hiveHandle)) { for (HiveFileStatus file : fileListingCache.listDataFiles( @@ -1197,13 +1239,13 @@ private List listPartitionsUncached(HiveTableHandle hive * regardless of column casing/order (do NOT derive the order from the value map / partition-key names). * A value equal to the HMS default-partition sentinel {@code __HIVE_DEFAULT_PARTITION__} is a genuine * SQL NULL — byte-parity with legacy {@code HiveExternalMetaCache.toListPartitionItem}, which marks the - * sentinel (and only the sentinel) null; the broader {@code isNullPartitionValue} (which also treats - * {@code \N}/null as null) is deliberately not used (HMS partition names never carry {@code \N}). + * sentinel (and only the sentinel) null; hudi's broader directory-name rule (which also treats + * {@code \N}/null as null) is deliberately not reused (HMS partition names never carry {@code \N}). */ private static List toPartitionValueNullFlags(List values) { List flags = new ArrayList<>(values.size()); for (String value : values) { - flags.add(ConnectorPartitionValues.HIVE_DEFAULT_PARTITION.equals(value)); + flags.add(ConnectorPartitionValues.NULL_PARTITION_NAME.equals(value)); } return flags; } @@ -1498,15 +1540,6 @@ private static String renderPartitionName(List partKeyNames, List partValues, } return true; } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java index 15f564866fafb5..dfe2174d4c2c54 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorProvider.java @@ -23,7 +23,9 @@ import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import java.util.Collections; import java.util.Map; +import java.util.Set; /** * SPI entry point for the Hive (HMS) connector plugin. @@ -43,6 +45,23 @@ public Connector create(Map properties, ConnectorContext context return new HiveConnector(properties, context); } + /** + * An HMS catalog has always created hive-engine tables, so {@code CREATE TABLE ... ENGINE=hive} keeps + * working. The name deliberately differs from {@link #getType()} and from the {@code hms} a table + * displays: the engine keyword and the catalog type are separate legacy vocabularies. + */ + @Override + public Set acceptedCreateTableEngineNames() { + return Collections.singleton("hive"); + } + + @Override + public boolean providesEventSource() { + // HiveConnector returns an HmsEventSource, and an HMS catalog must seed its event cursor even on an + // FE that never queries it (see MetastoreEventSyncDriver). + return true; + } + @Override public void validateProperties(Map properties) { // Reject removed metastore types at CREATE/ALTER CATALOG. This runs only for a user-issued statement, diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorTransaction.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorTransaction.java index 89acc1f0070fad..0a6bc19be186e4 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorTransaction.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorTransaction.java @@ -33,6 +33,7 @@ import org.apache.doris.connector.hms.HmsTableInfo; import org.apache.doris.connector.hms.HmsTypeMapping; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.FileEntry; import org.apache.doris.filesystem.FileSystem; import org.apache.doris.filesystem.FileSystemUtil; @@ -91,12 +92,13 @@ *

    fe-core couplings broken per design: query profiling dropped (D4); metastore access via the plugin * {@link HmsClient} SPI instead of {@code HiveMetadataOps} (D10); staging renames/deletes and object-store * multipart-upload complete/abort go through the engine-owned {@link FileSystem} borrowed via - * {@code context.getFileSystem(session)} (a per-catalog {@code SpiSwitchingFileSystem}, all schemes), with the + * {@code storage().getFileSystem(session)} (a per-catalog {@code SpiSwitchingFileSystem}, all schemes), with the * concrete {@link ObjFileSystem} resolved per object-store location via {@link FileSystem#forLocation} for the * MPU narrowing (D6); plugin-owned async pool threads each auth-wrapped (D5); full-ACID writes hard-rejected at * begin (D7); {@code rollback()} deletes staging + aborts MPUs (D9). * - *

    Live since the HMS cutover: {@code hms} is in {@code SPI_READY_TYPES}, so a {@code type=hms} table INSERT + *

    Live since the HMS cutover: an {@code hms} catalog is served by this connector plugin, so a + * {@code type=hms} table INSERT * routes through {@code HiveWritePlanProvider.planWrite} → {@link #beginWrite} → this class. The engine * {@link FileSystem} is borrowed (never closed here — the catalog owns its lifecycle). */ @@ -114,7 +116,7 @@ public class HiveConnectorTransaction implements ConnectorTransaction { private final ExecutorService fileSystemExecutor; private final Executor authWrappingExecutor; - // Captured at beginWrite (D6). Threaded to context.getFileSystem(session) so the engine hands back the + // Captured at beginWrite (D6). Threaded to storage().getFileSystem(session) so the engine hands back the // per-catalog borrowed FileSystem; the session reserves per-user identity (getUser()) — the current engine // impl resolves the FS at catalog level and ignores it, so a null session (rollback-before-begin) is safe. private ConnectorSession session; @@ -747,13 +749,13 @@ private synchronized void dropPartition( /** * Returns the engine-owned {@link FileSystem} for this write, borrowed via - * {@code context.getFileSystem(session)} (a per-catalog {@code SpiSwitchingFileSystem} that routes every + * {@code storage().getFileSystem(session)} (a per-catalog {@code SpiSwitchingFileSystem} that routes every * scheme — HDFS and object stores alike). The engine lazily builds and caches it per catalog, so this is a * cheap lookup; the connector borrows and must never close it (D6). MPU sites narrow to the concrete * {@link ObjFileSystem} via {@link FileSystem#forLocation}. */ private FileSystem getFileSystem() { - FileSystem engineFs = context.getFileSystem(session); + FileSystem engineFs = storage().getFileSystem(session); if (engineFs == null) { throw new DorisConnectorException("No engine FileSystem available for hive write transaction " + transactionId + " (catalog has no storage properties)"); @@ -1691,4 +1693,9 @@ THivePartitionUpdate getHivePartitionUpdate() { return hivePartitionUpdate; } } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileListingCache.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileListingCache.java index 6c9d0efcb54960..a62967c33760f2 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileListingCache.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveFileListingCache.java @@ -102,7 +102,8 @@ public class HiveFileListingCache { * The raw directory lister: the engine-injected Doris {@link FileSystem} in production * ({@link #listFromFileSystem}), a fake in unit tests. Injected so the cache's hit/miss/invalidation behaviour * is testable without a live filesystem (mirrors {@code HiveConnectorMetadata.estimateDataSize} injecting its - * {@code ToLongFunction} size source). The {@code fs} is borrowed from {@code ConnectorContext.getFileSystem} + * {@code ToLongFunction} size source). The {@code fs} is borrowed from the engine's + * {@code ConnectorStorageContext.getFileSystem} * (engine-owned, per-catalog, must NOT be closed by the connector). */ @FunctionalInterface diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java index 4da1b40adbd9b3..fe6aa3f0b10e55 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java @@ -24,10 +24,12 @@ import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.doris.connector.hms.HmsClient; import org.apache.doris.connector.hms.HmsPartitionInfo; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.FileEntry; import org.apache.doris.filesystem.FileSystem; import org.apache.doris.thrift.TFileCompressType; @@ -78,10 +80,6 @@ public class HiveScanPlanProvider implements ConnectorScanPlanProvider { /** Maximum number of partitions to list from HMS. */ private static final int MAX_PARTITIONS = 100000; - /** Scan node property keys. */ - public static final String PROP_FILE_FORMAT_TYPE = "file_format_type"; - public static final String PROP_PATH_PARTITION_KEYS = "path_partition_keys"; - public static final String PROP_LOCATION_PREFIX = "location."; /** * Connector-internal signal (consumed by {@link #populateScanLevelParams}) marking a full-ACID * (transactional) Hive scan; drives the scan-level {@code table_format_type} stamp. Not a BE-facing key. @@ -94,7 +92,7 @@ public class HiveScanPlanProvider implements ConnectorScanPlanProvider { private final HmsClient hmsClient; private final Map catalogProperties; // Engine-owned, per-catalog filesystem accessor. The non-ACID listing path borrows the Doris FileSystem via - // context.getFileSystem(session) (never closes it — the engine owns its lifecycle) to list partition + // storage().getFileSystem(session) (never closes it — the engine owns its lifecycle) to list partition // directories, replacing bare Hadoop FileSystem.get (FIX-HIVEFS: the hive plugin bundles no HDFS impl). private final ConnectorContext context; private final HiveReadTransactionManager readTxnManager; @@ -114,17 +112,14 @@ public HiveScanPlanProvider(HmsClient hmsClient, Map catalogProp } @Override - public ConnectorScanRangeType getScanRangeType() { - return ConnectorScanRangeType.FILE_SCAN; + public boolean supportsFileCache() { + // hive tables are read by BE's native parquet/orc/text readers, so the BE file cache applies. + return true; } @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - HiveTableHandle hiveHandle = (HiveTableHandle) handle; + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + HiveTableHandle hiveHandle = (HiveTableHandle) request.getTableHandle(); String dbName = hiveHandle.getDbName(); String tableName = hiveHandle.getTableName(); @@ -150,11 +145,11 @@ public List planScan( // Transactional (ACID) table: descend into base/delta directories under the query's write-id // snapshot and emit ACID-annotated ranges. Borrows the engine's per-catalog Doris FileSystem to // list (same source as the non-ACID branch below; the engine owns its lifecycle — never closed here). - planAcidScan(session, hiveHandle, partitions, context.getFileSystem(session), fileFormat, + planAcidScan(session, hiveHandle, partitions, storage().getFileSystem(session), fileFormat, splittable, targetSplitSize, ranges); } else { // Borrow the engine's per-catalog Doris FileSystem to list partition directories (see field javadoc). - FileSystem fs = context.getFileSystem(session); + FileSystem fs = storage().getFileSystem(session); for (PartitionScanInfo partition : partitions) { HiveFileFormat partFormat = partition.fileFormat != null ? partition.fileFormat : fileFormat; @@ -229,12 +224,9 @@ public TFileCompressType adjustFileCompressType(TFileCompressType inferred) { @Override public List planScanForPartitionBatch( ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit, + ConnectorScanRequest request, List partitionBatch) { - HiveTableHandle hiveHandle = (HiveTableHandle) handle; + HiveTableHandle hiveHandle = (HiveTableHandle) request.getTableHandle(); String dbName = hiveHandle.getDbName(); String tableName = hiveHandle.getTableName(); @@ -255,7 +247,7 @@ public List planScanForPartitionBatch( boolean splittable = fileFormat.isSplittable() && !isLzo; // Only the non-ACID path is reachable here (supportsBatchScan excludes transactional tables), so this // borrows the engine's per-catalog Doris FileSystem to list — no Hadoop Configuration is needed. - FileSystem fs = context.getFileSystem(session); + FileSystem fs = storage().getFileSystem(session); List ranges = new ArrayList<>(); for (PartitionScanInfo partition : partitions) { @@ -275,7 +267,7 @@ public List planScanForPartitionBatch( * surviving base/delta data files and delete-delta directories, and emits one ACID-annotated * {@link HiveScanRange} per data-file split. The BE subtracts the delete deltas on read.

    * - *

    Live path. Post-cutover {@code hms} is in {@code SPI_READY_TYPES}, so an hms-catalog + *

    Live path. Post-cutover an {@code hms} catalog is plugin-driven, so an hms-catalog * transactional table is a {@code PluginDrivenExternalTable} routed through {@code PluginDrivenScanNode} * straight into this method on a live query (the only read gate is the full-ACID ORC-format check below). * It opens a real metastore transaction/lock; the matching commit (lock release) is driven by @@ -386,12 +378,12 @@ public Map getScanNodeProperties( HiveFileFormat fileFormat = HiveFileFormat.detect( hiveHandle.getInputFormat(), hiveHandle.getSerializationLib(), readHiveJsonInOneColumn(session), hiveHandle.isFirstColumnString()); - props.put(PROP_FILE_FORMAT_TYPE, fileFormat.getFormatName()); + props.put(ScanNodePropertyKeys.FILE_FORMAT_TYPE, fileFormat.getFormatName()); // Partition key column names List partKeys = hiveHandle.getPartitionKeyNames(); if (partKeys != null && !partKeys.isEmpty()) { - props.put(PROP_PATH_PARTITION_KEYS, String.join(",", partKeys)); + props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS, String.join(",", partKeys)); } // Location properties (Hadoop/S3 config for BE file access). @@ -402,7 +394,8 @@ public Map getScanNodeProperties( // (hmsTable.getBackendStorageProperties()); the new path had dropped it. Empty for a null context // (offline tests) or a credential-less warehouse. if (context != null) { - context.getBackendStorageProperties().forEach((k, v) -> props.put(PROP_LOCATION_PREFIX + k, v)); + storage().getBackendStorageProperties() + .forEach((k, v) -> props.put(ScanNodePropertyKeys.LOCATION_PREFIX + k, v)); } // (2) Raw catalog aliases + inline fs./hadoop./dfs. keys. Emitted AFTER the canonical set so a user-inline // fs./hadoop. key wins; the s3./oss./cos./obs. aliases are harmless to BE (ignored by the native @@ -410,7 +403,7 @@ public Map getScanNodeProperties( for (Map.Entry entry : catalogProperties.entrySet()) { String key = entry.getKey(); if (isLocationProperty(key)) { - props.put(PROP_LOCATION_PREFIX + key, entry.getValue()); + props.put(ScanNodePropertyKeys.LOCATION_PREFIX + key, entry.getValue()); } } @@ -608,7 +601,7 @@ static boolean isLzoDataFile(String filePath) { /** * Normalizes a raw HMS storage URI into BE's canonical scheme for a BE-facing native reader path * (e.g. {@code s3a://}/{@code oss://}/{@code cos://} → {@code s3://}), delegating to the engine seam - * {@link ConnectorContext#normalizeStorageUri(String)} — the connector cannot import fe-core's + * {@link ConnectorStorageContext#normalizeStorageUri(String)} — the connector cannot import fe-core's * {@code LocationPath}. BE's native S3 file factory (S3URI) accepts ONLY {@code s3://}, so an un-normalized * {@code s3a://} scan path fails the native read with "Invalid S3 URI". Mirrors iceberg/paimon/hudi and hive's * OWN write path ({@code HiveWritePlanProvider}); legacy {@code HiveScanNode} normalized via the 2-arg @@ -616,7 +609,7 @@ static boolean isLzoDataFile(String filePath) { * unchanged. A null context (offline unit tests) preserves the raw URI. */ private String normalizeNativeUri(String rawUri) { - return context != null ? context.normalizeStorageUri(rawUri) : rawUri; + return context != null ? storage().normalizeStorageUri(rawUri) : rawUri; } /** @@ -716,4 +709,9 @@ private static final class PartitionScanInfo { this.fileFormat = fileFormat; } } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanRange.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanRange.java index a9539299e8c1e9..2102be430923b7 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanRange.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanRange.java @@ -19,7 +19,6 @@ import org.apache.doris.connector.api.scan.ConnectorPartitionValues; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TTableFormatFileDesc; import org.apache.doris.thrift.TTransactionalHiveDeleteDeltaDesc; @@ -78,11 +77,6 @@ private HiveScanRange(Builder builder) { : Collections.emptyMap(); } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Optional getPath() { return Optional.ofNullable(path); @@ -156,10 +150,12 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, // path-parsed columns-from-path; unset it, then re-set from partitionValues so BE receives the // authoritative keys/values/is_null. partitionValues keys are the partition column names (same // order as path_partition_keys, both from HiveTableHandle.getPartitionKeyNames), so the emitted - // bytes are unchanged from the legacy path. Use the NARROW HIVE_DEFAULT_PARTITION.equals (NOT - // ConnectorPartitionValues.normalize, which would also null a literal "\N"): an HMS partition - // value is either a real value or the __HIVE_DEFAULT_PARTITION__ directory sentinel, never a - // Java null; matching legacy normalizeColumnsFromPath, a null value maps to SQL NULL defensively. + // bytes are unchanged from the legacy path. Use the NARROW NULL_PARTITION_NAME.equals — hudi's + // wider directory-name rule (HudiScanRange.populateRangeParams, which also nulls a literal "\N") + // must NOT be reused here: an HMS partition value is either a real value or the + // __HIVE_DEFAULT_PARTITION__ directory sentinel, never a Java null, and a hive column may carry the + // two characters "\N" as DATA; matching legacy normalizeColumnsFromPath, a null value maps to SQL + // NULL defensively. rangeDesc.unsetColumnsFromPath(); rangeDesc.unsetColumnsFromPathKeys(); rangeDesc.unsetColumnsFromPathIsNull(); @@ -170,7 +166,7 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, for (Map.Entry entry : partitionValues.entrySet()) { String value = entry.getValue(); boolean nullValue = value == null - || ConnectorPartitionValues.HIVE_DEFAULT_PARTITION.equals(value); + || ConnectorPartitionValues.NULL_PARTITION_NAME.equals(value); keys.add(entry.getKey()); values.add(nullValue ? "" : value); isNull.add(nullValue); diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTextProperties.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTextProperties.java index e335d0accc871c..889f94f6612f58 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTextProperties.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveTextProperties.java @@ -17,6 +17,8 @@ package org.apache.doris.connector.hive; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; + import java.util.HashMap; import java.util.Map; @@ -27,8 +29,9 @@ *

    These properties are passed to BE via scan node properties so that * the text/CSV/JSON file reader can parse the data correctly.

    * - *

    The property keys mirror the constants used in fe-core's - * {@code HiveProperties} and {@code HiveMetaStoreClientHelper}.

    + *

    The output keys are the text-family keys declared in {@link ScanNodePropertyKeys} — they are shared + * with the engine rather than mirrored, so a mistyped suffix (which would silently disable that one + * attribute, e.g. leaving the whole row in the first column) is a compile error.

    */ public final class HiveTextProperties { @@ -83,15 +86,13 @@ public final class HiveTextProperties { private static final String QUOTE_CHAR = "quoteChar"; private static final String ESCAPE_CHAR = "escapeChar"; - // Output property key prefix for scan node properties - public static final String PROP_PREFIX = "hive.text."; - private HiveTextProperties() { } /** * Extracts text format properties from the SerDe parameters of a table or partition. - * Returns properties prefixed with {@link #PROP_PREFIX} for use in scan node properties. + * Returns properties keyed by the text-family scan node property keys declared in + * {@link ScanNodePropertyKeys} (all of them share {@link ScanNodePropertyKeys#TEXT_PROPERTY_PREFIX}). * * @param serDeLib the SerDe library class name * @param sdParams the StorageDescriptor / SerDeInfo parameters @@ -119,8 +120,8 @@ public static Map extract(String serDeLib, // Skip header count from table parameters int skipLines = getSkipHeaderCount(tableParams); - result.put(PROP_PREFIX + "skip_lines", String.valueOf(skipLines)); - result.put(PROP_PREFIX + "serde_lib", serDeLib); + result.put(ScanNodePropertyKeys.TEXT_SKIP_LINES, String.valueOf(skipLines)); + result.put(ScanNodePropertyKeys.TEXT_SERDE_LIB, serDeLib); return result; } @@ -129,58 +130,58 @@ private static void extractTextSerDeProps(Map sdParams, // Column separator. Hive stores single-char delimiters as their numeric byte value (the default // LazySimpleSerDe field delimiter is serialization.format="1" == byte 0x01, NOT the character // '1'), so they must be decoded via getByte(). MultiDelimitSerDe keeps its raw multi-char value. - result.put(PROP_PREFIX + "column_separator", + result.put(ScanNodePropertyKeys.TEXT_COLUMN_SEPARATOR, getFieldDelimiter(sdParams, tableParams, supportMultiChar)); // Line delimiter - result.put(PROP_PREFIX + "line_delimiter", + result.put(ScanNodePropertyKeys.TEXT_LINE_DELIMITER, getByte(serdeVal(sdParams, tableParams, LINE_DELIM), DEFAULT_LINE_DELIM)); // MapKV delimiter - result.put(PROP_PREFIX + "mapkv_delimiter", + result.put(ScanNodePropertyKeys.TEXT_MAPKV_DELIMITER, getByte(serdeVal(sdParams, tableParams, MAPKEY_DELIM), DEFAULT_MAPKV_DELIM)); // Collection delimiter (Hive2 "colelction.delim" typo first, then Hive3 "collection.delim") - result.put(PROP_PREFIX + "collection_delimiter", + result.put(ScanNodePropertyKeys.TEXT_COLLECTION_DELIMITER, getByte(serdeVal(sdParams, tableParams, COLLECTION_DELIM_HIVE2, COLLECTION_DELIM), DEFAULT_COLLECTION_DELIM)); // Escape delimiter: emitted only when the SerDe sets it, decoded via getByte String escape = serdeVal(sdParams, tableParams, ESCAPE_DELIM); if (escape != null) { - result.put(PROP_PREFIX + "escape", getByte(escape, DEFAULT_ESCAPE_DELIM)); + result.put(ScanNodePropertyKeys.TEXT_ESCAPE, getByte(escape, DEFAULT_ESCAPE_DELIM)); } // Null format (raw string; NOT byte-decoded) String nullFormat = serdeVal(sdParams, tableParams, SERIALIZATION_NULL_FORMAT); - result.put(PROP_PREFIX + "null_format", nullFormat != null ? nullFormat : DEFAULT_NULL_FORMAT); + result.put(ScanNodePropertyKeys.TEXT_NULL_FORMAT, nullFormat != null ? nullFormat : DEFAULT_NULL_FORMAT); } private static void extractCsvSerDeProps(Map params, Map result) { - result.put(PROP_PREFIX + "column_separator", + result.put(ScanNodePropertyKeys.TEXT_COLUMN_SEPARATOR, getParamOrDefault(params, SEPARATOR_CHAR, ",")); - result.put(PROP_PREFIX + "line_delimiter", getLineDelimiter(params)); + result.put(ScanNodePropertyKeys.TEXT_LINE_DELIMITER, getLineDelimiter(params)); String quoteChar = getParamOrDefault(params, QUOTE_CHAR, "\""); - result.put(PROP_PREFIX + "enclose", quoteChar); + result.put(ScanNodePropertyKeys.TEXT_ENCLOSE, quoteChar); // #65501: BE strips the wrapping quotes only when the enclose char is exactly the double-quote '"'. // The connector owns this CSV serde semantics, so decide here and pass an explicit flag; the generic // PluginDrivenScanNode then just applies it instead of trimming for any enclose char. Compare the // first byte, matching how the node sets enclose (enclose.getBytes()[0]) and BE's getEnclose() == '"'. boolean trimDoubleQuotes = !quoteChar.isEmpty() && quoteChar.getBytes()[0] == (byte) '"'; - result.put(PROP_PREFIX + "trim_double_quotes", String.valueOf(trimDoubleQuotes)); + result.put(ScanNodePropertyKeys.TEXT_TRIM_DOUBLE_QUOTES, String.valueOf(trimDoubleQuotes)); String escapeChar = getParamOrDefault(params, ESCAPE_CHAR, "\\"); - result.put(PROP_PREFIX + "escape", escapeChar); - result.put(PROP_PREFIX + "null_format", ""); + result.put(ScanNodePropertyKeys.TEXT_ESCAPE, escapeChar); + result.put(ScanNodePropertyKeys.TEXT_NULL_FORMAT, ""); } private static void extractJsonSerDeProps(String serDeLib, Map sdParams, Map tableParams, Map result) { - result.put(PROP_PREFIX + "column_separator", "\t"); - result.put(PROP_PREFIX + "line_delimiter", "\n"); - result.put(PROP_PREFIX + "is_json", "true"); - result.put(PROP_PREFIX + "json_serde_lib", serDeLib); + result.put(ScanNodePropertyKeys.TEXT_COLUMN_SEPARATOR, "\t"); + result.put(ScanNodePropertyKeys.TEXT_LINE_DELIMITER, "\n"); + result.put(ScanNodePropertyKeys.TEXT_IS_JSON, "true"); + result.put(ScanNodePropertyKeys.TEXT_PROPERTY_PREFIX + "json_serde_lib", serDeLib); // OpenX-only: skip malformed rows when the serde/table sets ignore.malformed.json (table-param over // sd-param, default false). Mirrors legacy HiveScanNode's OPENX_JSON_SERDE branch — the hcatalog/hive2 // JSON serdes never carried this flag, so scope it to OpenX to keep exact legacy branch parity. if (OPENX_JSON_SERDE.equals(serDeLib)) { String ignoreMalformed = serdeVal(sdParams, tableParams, IGNORE_MALFORMED_JSON); - result.put(PROP_PREFIX + "openx_ignore_malformed", + result.put(ScanNodePropertyKeys.TEXT_OPENX_IGNORE_MALFORMED, ignoreMalformed != null ? ignoreMalformed : DEFAULT_IGNORE_MALFORMED_JSON); } } diff --git a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWritePlanProvider.java b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWritePlanProvider.java index e8b4f5dd849ab6..d77964868c483e 100644 --- a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWritePlanProvider.java +++ b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveWritePlanProvider.java @@ -30,6 +30,7 @@ import org.apache.doris.connector.hms.HmsTableInfo; import org.apache.doris.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.thrift.TDataSink; import org.apache.doris.thrift.TDataSinkType; @@ -71,7 +72,8 @@ * {@link #requiresPartitionHashWrite()} (hash-by-partition, no local sort) matches the legacy * {@code PhysicalHiveTableSink}.

    * - *

    Live since the hms flip. {@code hms} is in {@code SPI_READY_TYPES}, so a type=hms INSERT routes + *

    Live since the hms flip. An {@code hms} catalog is served by this connector plugin, so a + * type=hms INSERT routes * here via {@code PhysicalPlanTranslator} → {@code getWritePlanProvider} → {@link #planWrite}; * {@link #planWrite} requires the executor-bound connector transaction and fails loud if absent.

    */ @@ -150,15 +152,15 @@ public boolean requiresPartitionHashWrite() { HiveWriteContext buildWriteContext(ConnectorSession session, HiveTableHandle tableHandle, HmsTableInfo table, ConnectorWriteHandle handle) { String rawLocation = table.getLocation(); - TFileType fileType = TFileType.valueOf(context.getBackendFileType(rawLocation, Collections.emptyMap())); + TFileType fileType = TFileType.valueOf(storage().getBackendFileType(rawLocation, Collections.emptyMap())); String writePath = fileType == TFileType.FILE_S3 - ? context.normalizeStorageUri(rawLocation, Collections.emptyMap()) + ? storage().normalizeStorageUri(rawLocation, Collections.emptyMap()) : createTempPath(session, rawLocation); WriteOperation op = handle.getWriteOperation(); if (op == WriteOperation.INSERT && handle.isOverwrite()) { op = WriteOperation.OVERWRITE; } - return new HiveWriteContext(op, handle.isOverwrite(), handle.getWriteContext(), + return new HiveWriteContext(op, handle.isOverwrite(), handle.getStaticPartitionSpec(), session.getQueryId(), fileType, writePath); } @@ -266,7 +268,7 @@ private List buildExistingPartitions(HmsTableInfo table) { locationParams.setWritePath(location); locationParams.setTargetPath(location); locationParams.setFileType(TFileType.valueOf( - context.getBackendFileType(location, Collections.emptyMap()))); + storage().getBackendFileType(location, Collections.emptyMap()))); hivePartition.setLocation(locationParams); partitions.add(hivePartition); } @@ -324,8 +326,8 @@ private Map buildHadoopConfig() { // the BE writer a config with no fs.jfs.impl and libhdfs fails "No FileSystem for scheme jfs". Emitted // first so the typed getStorageProperties() overlay still wins wherever it produces a value (object // stores / HDFS), keeping their behavior byte-identical. - merged.putAll(context.getBackendStorageProperties()); - for (StorageProperties sp : context.getStorageProperties()) { + merged.putAll(storage().getBackendStorageProperties()); + for (StorageProperties sp : storage().getStorageProperties()) { sp.toBackendProperties().ifPresent(b -> merged.putAll(b.toMap())); } } @@ -336,7 +338,7 @@ private Map buildHadoopConfig() { // message legacy BaseExternalTableDataSink.getBrokerAddresses threw), so a broker write never ships BE an // empty broker list. private List resolveBrokerAddresses() { - List addresses = context.getBrokerAddresses(); + List addresses = storage().getBrokerAddresses(); if (addresses.isEmpty()) { throw new DorisConnectorException("No alive broker."); } @@ -367,4 +369,9 @@ private HiveConnectorTransaction currentTransaction(ConnectorSession session) { } return (HiveConnectorTransaction) transaction.get(); } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeConnectorContext.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeConnectorContext.java index d1baf0771dbb26..30b7205c4c2268 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeConnectorContext.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/FakeConnectorContext.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.hive; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import java.util.Collections; import java.util.Map; @@ -27,7 +28,14 @@ * channel through which fe-core threads the FE-global CREATE TABLE defaults). Everything else uses the * interface defaults. */ -public class FakeConnectorContext implements ConnectorContext { +public class FakeConnectorContext implements ConnectorContext, ConnectorStorageContext { + + // This double is both halves of the context, so a subclass that overrides a storage method (several + // tests do, anonymously) still has the connector reach that override. + @Override + public ConnectorStorageContext getStorageContext() { + return this; + } private final String catalogName; private final long catalogId; diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorCapabilitiesTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorCapabilitiesTest.java index ec37f027dd8ac8..efb7fe2737cef0 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorCapabilitiesTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorCapabilitiesTest.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.hive; import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorPassthroughSqlOps; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -63,8 +64,10 @@ public void withholdsCapabilitiesWithoutSupportingSpi() { // Needs the connector to emit the table location (show.location) + a rendering-parity decision first. Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL), "SUPPORTS_SHOW_CREATE_DDL needs location emission + a SHOW CREATE rendering decision"); - // Hive exposes no query() TVF (no getColumnsFromQuery). - Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_PASSTHROUGH_QUERY), + // Hive exposes no query() TVF: it does not implement ConnectorPassthroughSqlOps, which is the whole + // declaration (there is no capability flag for it). Pinned here because the metadata is what the + // engine's query()/EXECUTE_STMT entry points type-check. + Assertions.assertFalse(ConnectorPassthroughSqlOps.class.isAssignableFrom(HiveConnectorMetadata.class), "hive has no passthrough query()"); // Legacy SHOW PARTITIONS lists names only; listPartitions emits UNKNOWN stats. Assertions.assertFalse(caps.contains(ConnectorCapability.SUPPORTS_PARTITION_STATS), diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorContractTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorContractTest.java new file mode 100644 index 00000000000000..f07dd31b40df66 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorContractTest.java @@ -0,0 +1,79 @@ +// 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.ConnectorContractValidator; +import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.EnumSet; + +/** + * The positive sample {@link ConnectorContractValidator}'s partition-hash-write invariants were missing. + * + *

    WHY (Rule 9): hive is the ONLY connector that declares {@code requiresPartitionHashWrite}, and its tests + * did not call the validator — so invariants #4 (hash write implies parallel write AND full-schema write + * order) and #5 (the two partition-distribution arms are mutually exclusive) were exercised solely by + * fe-core's fake connector. A fake proves the validator rejects a bad declaration; only a real connector + * proves the shipped declaration is a good one. Dropping any of the three traits hive declares, or adding + * {@code requiresPartitionLocalSort} alongside the hash arm, must fail loud here.

    + * + *

    The provider is built directly ({@code HiveWritePlanProvider}'s constructor is pure field assignment) + * with a null client: the validator reads only the declared traits, none of which touch the metastore. The + * no-arg {@link HiveConnector#getWritePlanProvider()} is not used because it builds a real HmsClient, whose + * Hadoop stack is absent from connector unit tests.

    + */ +public class HiveConnectorContractTest { + + /** A hive connector whose connector-level write provider is the REAL one, minus the metastore client. */ + private HiveConnector connectorWithRealWriteProvider() { + FakeConnectorContext context = new FakeConnectorContext(); + return new HiveConnector(Collections.emptyMap(), context) { + @Override + public ConnectorWritePlanProvider getWritePlanProvider() { + return new HiveWritePlanProvider(null, Collections.emptyMap(), context); + } + }; + } + + @Test + public void declaredWriteCapabilitiesMatchAndPassContractValidator() { + HiveConnector connector = connectorWithRealWriteProvider(); + + ConnectorWritePlanProvider writeProvider = connector.getWritePlanProvider(); + Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE), + writeProvider.supportedOperations(), + "hive writes are INSERT and INSERT OVERWRITE; no branch/delete/merge/rewrite"); + Assertions.assertFalse(writeProvider.supportsWriteBranch(), + "hive has no named table branches"); + // The triad invariant #4 checks: the sink indexes partition columns by full-schema position and + // distributes in parallel, so the hash arm is meaningless without these two. + Assertions.assertTrue(writeProvider.requiresParallelWrite()); + Assertions.assertTrue(writeProvider.requiresFullSchemaWriteOrder()); + Assertions.assertTrue(writeProvider.requiresPartitionHashWrite(), + "hive is the connector that exercises the hash-without-sort arm"); + Assertions.assertFalse(writeProvider.requiresPartitionLocalSort(), + "invariant #5: a connector picks at most one partition-distribution arm"); + + ConnectorContractValidator.validate(connector, "hive"); + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataDdlTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataDdlTest.java index 34f3c9a9872fdb..2afd824d91b777 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataDdlTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataDdlTest.java @@ -183,8 +183,7 @@ public void createTableRejectsRangePartition() { ConnectorPartitionSpec range = new ConnectorPartitionSpec( ConnectorPartitionSpec.Style.RANGE, Collections.singletonList(new ConnectorPartitionField("dt", "identity", - Collections.emptyList())), - Collections.emptyList()); + Collections.emptyList()))); // WHY: hive supports only LIST-style partitioning (legacy rejected RANGE). MUTATION: accepting RANGE // would build an invalid hive partition spec. DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, @@ -200,7 +199,6 @@ public void createTableRejectsExplicitPartitionValues() { ConnectorPartitionSpec.Style.LIST, Collections.singletonList(new ConnectorPartitionField("dt", "identity", Collections.emptyList())), - Collections.emptyList(), true /* hasExplicitPartitionValues */); // WHY: a hive external table discovers partitions from the data layout, so explicit partition value // definitions are rejected (legacy parity). The neutral converter drops the value expressions but @@ -215,16 +213,21 @@ public void createTableRejectsExplicitPartitionValues() { @Test public void createTableListPartitionThreadsPartitionKeys() { RecordingHmsClient client = new RecordingHmsClient(); + // Partitioning on two columns needs both declared, and hive requires the partition columns at the end of + // the schema in spec order -- the default fixture only carries id + dt. + List cols = Arrays.asList( + new ConnectorColumn("id", ConnectorType.of("INT"), "id", true, null), + new ConnectorColumn("dt", ConnectorType.of("STRING"), null, true, null), + new ConnectorColumn("region", ConnectorType.of("STRING"), null, true, null)); ConnectorPartitionSpec list = new ConnectorPartitionSpec( ConnectorPartitionSpec.Style.LIST, Arrays.asList( new ConnectorPartitionField("dt", "identity", Collections.emptyList()), - new ConnectorPartitionField("region", "identity", Collections.emptyList())), - Collections.emptyList()); + new ConnectorPartitionField("region", "identity", Collections.emptyList()))); // WHY: the LIST partition columns become the metastore partition keys (order preserved). MUTATION: // dropping the field-name threading yields a non-partitioned table. metadata(client, Collections.emptyMap(), Collections.emptyMap()) - .createTable(session(), request().partitionSpec(list).build()); + .createTable(session(), request().columns(cols).partitionSpec(list).build()); Assertions.assertEquals(Arrays.asList("dt", "region"), client.lastCreateTable.getPartitionKeys()); } @@ -233,7 +236,7 @@ public void createTableListPartitionThreadsPartitionKeys() { public void createTableAllowsColumnDefaultsOnNonDlfCatalog() { RecordingHmsClient client = new RecordingHmsClient(); List cols = Arrays.asList( - new ConnectorColumn("id", ConnectorType.of("INT"), null, false, null), + new ConnectorColumn("id", ConnectorType.of("INT"), null, true, null), new ConnectorColumn("v", ConnectorType.of("INT"), null, true, "5")); // WHY: a plain HMS catalog keeps column defaults; the guard must only fire for DLF. MUTATION: an // unconditional guard would wrongly reject this. @@ -365,9 +368,15 @@ private static HiveConnectorMetadata metadata(RecordingHmsClient client, return new HiveConnectorMetadata(client, catalogProps, new FakeConnectorContext(env)); } + /** + * Columns are nullable: hive rejects a {@code NOT NULL} column up front (validateColumns runs before every + * other createTable check), so a NOT NULL fixture column would short-circuit every test in this class before + * it reaches the behaviour it actually pins. The NOT NULL rule itself is covered by + * {@code HiveCreateTableValidationTest#notNullColumnIsRejected}. + */ private static ConnectorCreateTableRequest.Builder request() { List cols = Arrays.asList( - new ConnectorColumn("id", ConnectorType.of("INT"), "id", false, null), + new ConnectorColumn("id", ConnectorType.of("INT"), "id", true, null), new ConnectorColumn("dt", ConnectorType.of("STRING"), null, true, null)); return ConnectorCreateTableRequest.builder() .dbName("db1") diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSchemaTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSchemaTest.java index 23aabc8397d54a..090c4accdba6e7 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSchemaTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataSchemaTest.java @@ -113,22 +113,8 @@ private static HmsTableInfo.Builder csvTypedTable(String serdeLib) { .parameters(Collections.emptyMap()); } - private static String perTableCapabilities(ConnectorTableSchema schema) { - return schema.getProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY); - } - - /** Membership test over the CSV marker (order-independent, robust as more per-table capabilities are added). */ private static boolean hasCapability(ConnectorTableSchema schema, ConnectorCapability capability) { - String csv = perTableCapabilities(schema); - if (csv == null) { - return false; - } - for (String name : csv.split(",")) { - if (name.trim().equals(capability.name())) { - return true; - } - } - return false; + return schema.getTableCapabilities().contains(capability); } @Test @@ -346,7 +332,7 @@ public void testTopNLazyCapabilityMarkerAbsentForView() { // supportedHiveTopNLazyTable which returns false for a view BEFORE the format check. ConnectorTableSchema schema = schemaOf( unpartitionedTable(PARQUET_INPUT_FORMAT).tableType("VIRTUAL_VIEW").build()); - Assertions.assertNull(perTableCapabilities(schema)); + Assertions.assertTrue(schema.getTableCapabilities().isEmpty()); } @Test @@ -355,7 +341,7 @@ public void testTopNLazyCapabilityMarkerAbsentForIcebergOnHms() { // hive connector must NOT claim Top-N for it even though its data files are parquet (detect() != HIVE). ConnectorTableSchema schema = schemaOf(unpartitionedTable(PARQUET_INPUT_FORMAT) .parameters(Collections.singletonMap("table_type", "ICEBERG")).build()); - Assertions.assertNull(perTableCapabilities(schema)); + Assertions.assertTrue(schema.getTableCapabilities().isEmpty()); } @Test 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 34ab1b1728bd8d..a5532133929b9b 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 @@ -30,6 +30,7 @@ import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.ddl.BranchChange; +import org.apache.doris.connector.api.ddl.ConnectorColumnPath; import org.apache.doris.connector.api.ddl.ConnectorColumnPosition; import org.apache.doris.connector.api.ddl.DropRefChange; import org.apache.doris.connector.api.ddl.PartitionFieldChange; @@ -242,6 +243,11 @@ public void everyAlterDdlAndValidateMethodForwardsAForeignHandleToTheSibling() { md.renameColumn(session, foreignHandle, "a", "b"); md.modifyColumn(session, foreignHandle, null, null); md.reorderColumns(session, foreignHandle, Collections.emptyList()); + md.addNestedColumn(session, foreignHandle, null, null, null); + md.dropNestedColumn(session, foreignHandle, null); + md.renameNestedColumn(session, foreignHandle, null, "b"); + md.modifyNestedColumn(session, foreignHandle, null, null, null); + md.modifyColumnComment(session, foreignHandle, null, "c"); md.createOrReplaceBranch(session, foreignHandle, null); md.createOrReplaceTag(session, foreignHandle, null); md.dropBranch(session, foreignHandle, null); @@ -295,6 +301,15 @@ public void hiveHandleAlterDdlThrowsAndValidateIsNoopAndNeverConsultsSibling() { assertThrowsMessage(() -> md.modifyColumn(session, hive, null, null), "MODIFY COLUMN not supported"); assertThrowsMessage(() -> md.reorderColumns(session, hive, Collections.emptyList()), "REORDER COLUMNS not supported"); + assertThrowsMessage(() -> md.addNestedColumn(session, hive, null, null, null), + "nested ADD COLUMN not supported"); + assertThrowsMessage(() -> md.dropNestedColumn(session, hive, null), "nested DROP COLUMN not supported"); + assertThrowsMessage(() -> md.renameNestedColumn(session, hive, null, "b"), + "nested RENAME COLUMN not supported"); + assertThrowsMessage(() -> md.modifyNestedColumn(session, hive, null, null, null), + "nested MODIFY COLUMN not supported"); + assertThrowsMessage(() -> md.modifyColumnComment(session, hive, null, "c"), + "MODIFY COLUMN COMMENT 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"); @@ -354,7 +369,7 @@ public ConnectorTransaction beginTransaction(ConnectorSession session) { @Test public void foreignHandleSchemaReflectsSiblingScanCapabilitiesAsPerTableMarker() { - // Option C: fe-core's PluginDrivenExternalTable.hasScanCapability reads only the CATALOG (hive) connector, + // Option C: fe-core's PluginDrivenExternalTable.hasCapability reads only the CATALOG (hive) connector, // never the embedded sibling — so the hive gateway must reflect the sibling's connector-wide scan // capabilities onto the delegated schema as a per-table marker, or an iceberg-on-HMS table silently loses // auto-analyze / Top-N lazy / nested-column prune (all of which the iceberg sibling declares connector-wide). @@ -370,28 +385,60 @@ public void foreignHandleSchemaReflectsSiblingScanCapabilitiesAsPerTableMarker() SiblingOwner.ICEBERG_LABEL)); 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(",")); - Assertions.assertTrue(names.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE.name()), - "auto-analyze must survive the delegation as a per-table marker"); - Assertions.assertTrue(names.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE.name()), - "Top-N lazy must survive the delegation as a per-table marker"); - Assertions.assertTrue(names.contains(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE.name()), - "nested-column prune must survive the delegation as a per-table marker"); + Set reflected = schema.getTableCapabilities(); + Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE), + "auto-analyze must survive the delegation as a per-table capability"); + Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE), + "Top-N lazy must survive the delegation as a per-table capability"); + Assertions.assertTrue(reflected.contains(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE), + "nested-column prune must survive the delegation as a per-table capability"); } @Test - public void foreignHandleSchemaUnchangedWhenSiblingDeclaresNoCapabilities() { - // A sibling declaring an EMPTY capability set hits the ownerCaps.isEmpty() early-return in - // reflectSiblingScanCapabilities -> the sibling schema is returned untouched -> no marker is stamped. This - // guards the empty-owner branch specifically; the real hudi-on-HMS withholding (a NON-empty sibling that - // lacks auto-analyze) is pinned by foreignHandleSchemaWithholdsAutoAnalyzeFromRealHudiSibling below. - // MUTATION: dropping the isEmpty() early-return and stamping an (empty) marker unconditionally -> red here. + public void foreignHandleSchemaReflectsOnlyThePerTableResolvedCapabilitySubset() { + // WHY: fe-core resolves only a FIXED subset of capabilities per-table; every other one is answered + // catalog-wide and a marker entry for it is discarded unread. Reflecting the sibling's WHOLE set would + // therefore leave an iceberg-on-HMS table's SHOW CREATE TABLE / view / sort-order behaviour hanging on an + // implementation detail (how narrowly the engine happens to read the marker) instead of on an explicit + // decision here. The gateway reflects SIBLING_INHERITABLE_CAPABILITIES and nothing else. + // MUTATION: widening the reflection back to owner.getCapabilities() -> the marker gains SHOW_CREATE_DDL / + // SORT_ORDER -> red here, and an engine-side widening would silently change delegated-table behaviour. + Set siblingCaps = EnumSet.of( + ConnectorCapability.SUPPORTS_SHOW_CREATE_DDL, + ConnectorCapability.SUPPORTS_SORT_ORDER, + ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE); + HiveConnectorMetadata md = new HiveConnectorMetadata(null, Collections.emptyMap(), new FakeConnectorContext(), + SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(new CapabilityDeclaringSiblingConnector(siblingCaps), + SiblingOwner.ICEBERG_LABEL)); + + ConnectorTableSchema schema = md.getTableSchema(session, foreignHandle); + Assertions.assertEquals(EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE), + schema.getTableCapabilities(), + "only the per-table-resolved subset may be reflected onto a delegated table"); + + // A NON-empty sibling declaring none of the subset must inherit nothing. + HiveConnectorMetadata noneInheritable = new HiveConnectorMetadata(null, Collections.emptyMap(), + new FakeConnectorContext(), SUPPLIER_MUST_NOT_BE_USED, SUPPLIER_MUST_NOT_BE_USED, + handle -> new SiblingOwner(new CapabilityDeclaringSiblingConnector( + EnumSet.of(ConnectorCapability.SUPPORTS_VIEW, ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT)), + SiblingOwner.ICEBERG_LABEL)); + Assertions.assertTrue(noneInheritable.getTableSchema(session, foreignHandle) + .getTableCapabilities().isEmpty(), + "a sibling declaring only catalog-wide capabilities must contribute no per-table capability"); + } + + @Test + public void foreignHandleSchemaUnchangedWhenSiblingDeclaresNoInheritableCapability() { + // A sibling declaring an EMPTY capability set leaves the inherited subset empty -> the sibling schema is + // returned untouched -> no marker is stamped. The sibling-declares-only-catalog-wide-capabilities case + // takes the same branch and is pinned in + // foreignHandleSchemaReflectsOnlyThePerTableResolvedCapabilitySubset above. + // MUTATION: dropping the isEmpty() early-return and rebuilding the schema unconditionally -> red here. HiveConnectorMetadata md = withSibling(); // RecordingSiblingConnector declares no capabilities ConnectorTableSchema schema = md.getTableSchema(session, foreignHandle); - Assertions.assertNull(schema.getProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY), - "no marker when the sibling declares no capabilities"); + Assertions.assertTrue(schema.getTableCapabilities().isEmpty(), + "nothing inherited when the sibling declares no capabilities"); } // ============== per-statement sibling-metadata funnel (HMS heterogeneous gateway) ============== @@ -555,7 +602,9 @@ private static final class RecordingSiblingMetadata implements ConnectorMetadata // completeness lock for §4.4 W1: dropping a guard, or adding one that should not forward, fails the test). static final List EXPECTED_WRITE_METHODS = Collections.unmodifiableList(Arrays.asList( "renameTable", "addColumn", "addColumns", "dropColumn", "renameColumn", "modifyColumn", - "reorderColumns", "createOrReplaceBranch", "createOrReplaceTag", "dropBranch", "dropTag", + "reorderColumns", "addNestedColumn", "dropNestedColumn", "renameNestedColumn", + "modifyNestedColumn", "modifyColumnComment", + "createOrReplaceBranch", "createOrReplaceTag", "dropBranch", "dropTag", "addPartitionField", "dropPartitionField", "replacePartitionField", "validateRowLevelDmlMode", "validateStaticPartitionColumns", "validateWritePartitionNames")); @@ -764,6 +813,36 @@ public void reorderColumns(ConnectorSession session, ConnectorTableHandle handle calls.add("reorderColumns"); } + @Override + public void addNestedColumn(ConnectorSession session, ConnectorTableHandle handle, ConnectorColumnPath path, + ConnectorColumn column, ConnectorColumnPosition position) { + calls.add("addNestedColumn"); + } + + @Override + public void dropNestedColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path) { + calls.add("dropNestedColumn"); + } + + @Override + public void renameNestedColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path, String newName) { + calls.add("renameNestedColumn"); + } + + @Override + public void modifyNestedColumn(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path, ConnectorColumn column, ConnectorColumnPosition position) { + calls.add("modifyNestedColumn"); + } + + @Override + public void modifyColumnComment(ConnectorSession session, ConnectorTableHandle handle, + ConnectorColumnPath path, String comment) { + calls.add("modifyColumnComment"); + } + @Override public void createOrReplaceBranch(ConnectorSession session, ConnectorTableHandle handle, BranchChange branch) { diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableCommentTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableCommentTest.java new file mode 100644 index 00000000000000..4ecb3b96943637 --- /dev/null +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorMetadataTableCommentTest.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.hive; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorStatementScope; +import org.apache.doris.connector.hms.HmsClient; +import org.apache.doris.connector.hms.HmsClientException; +import org.apache.doris.connector.hms.HmsDatabaseInfo; +import org.apache.doris.connector.hms.HmsPartitionInfo; +import org.apache.doris.connector.hms.HmsTableInfo; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Pins {@link HiveConnectorMetadata#getTableComment} for an iceberg-on-HMS table read through the gateway. + * + *

    WHY this matters: the comment accessor takes a database and table NAME, never a + * {@code ConnectorTableHandle}, so the gateway cannot route it to the iceberg sibling the way it routes every + * other foreign-table operation — there is no handle to discriminate on. Before this was handled explicitly, + * an iceberg table showed its comment when read through a dedicated iceberg catalog and showed nothing when + * read through an HMS gateway catalog: a blank {@code COMMENT} clause in SHOW CREATE TABLE, a blank + * {@code information_schema.tables.TABLE_COMMENT} and a blank SHOW TABLE STATUS Comment column, for the same + * table. Iceberg's HMS catalog mirrors the comment into the HMS table parameters on every commit, so the + * gateway answers from the metastore table it already reads.

    + * + *

    The plain-hive assertions are the other half of the contract: a plain hive table must keep the empty + * comment legacy returned, so this change cannot alter what any existing hive deployment displays.

    + */ +public class HiveConnectorMetadataTableCommentTest { + + private final ConnectorSession session = new ScopeSession(1L, "q1", ConnectorStatementScope.NONE); + + @Test + public void icebergOnHmsTableReportsTheCommentStoredInTheMetastore() { + Map params = new HashMap<>(); + params.put("table_type", "ICEBERG"); + params.put("comment", "a comment written through the iceberg catalog"); + HiveConnectorMetadata md = metadataFor(icebergTable(params)); + + Assertions.assertEquals("a comment written through the iceberg catalog", + md.getTableComment(session, "db", "t"), + "an iceberg-on-HMS table must report the same comment the dedicated iceberg catalog reports"); + } + + @Test + public void icebergOnHmsTableWithoutACommentReportsEmpty() { + HiveConnectorMetadata md = metadataFor(icebergTable(Collections.singletonMap("table_type", "ICEBERG"))); + + Assertions.assertEquals("", md.getTableComment(session, "db", "t"), + "an iceberg table with no comment must report the empty comment, never null"); + } + + @Test + public void plainHiveTableKeepsItsLegacyEmptyComment() { + Map params = new HashMap<>(); + params.put("comment", "a hive table comment that legacy never surfaced"); + HmsTableInfo hive = HmsTableInfo.builder() + .dbName("db").tableName("t").tableType("MANAGED_TABLE") + .inputFormat("org.apache.hadoop.mapred.TextInputFormat") + .parameters(params) + .build(); + + Assertions.assertEquals("", metadataFor(hive).getTableComment(session, "db", "t"), + "a plain hive table must keep the empty comment legacy HMSExternalTable returned; surfacing it " + + "here would change what every existing hive deployment displays"); + } + + @Test + public void anUnreadableTableDegradesToTheEmptyCommentInsteadOfFailing() { + HiveConnectorMetadata md = new HiveConnectorMetadata(new ThrowingHmsClient(), Collections.emptyMap(), + new FakeConnectorContext()); + + Assertions.assertEquals("", md.getTableComment(session, "db", "gone"), + "the comment is a display value fetched opportunistically; a metastore miss must not fail the " + + "statement that is merely rendering a table list"); + } + + private static HmsTableInfo icebergTable(Map params) { + return HmsTableInfo.builder() + .dbName("db").tableName("t").tableType("EXTERNAL_TABLE") + .parameters(params) + .build(); + } + + private HiveConnectorMetadata metadataFor(HmsTableInfo tableInfo) { + // The 3-arg constructor installs fail-loud sibling suppliers: reaching for the iceberg sibling here would + // blow up, which is exactly the point — the comment must be answered from the metastore table alone, + // without building a sibling connector or loading iceberg metadata. + return new HiveConnectorMetadata(new FakeHmsClient(tableInfo), Collections.emptyMap(), + new FakeConnectorContext()); + } + + /** Serves one prebuilt table; every other operation fails loud. */ + private static class FakeHmsClient implements HmsClient { + private final HmsTableInfo table; + + FakeHmsClient(HmsTableInfo table) { + this.table = table; + } + + @Override + public boolean tableExists(String dbName, String tableName) { + return true; + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + return table; + } + + @Override + public List listDatabases() { + throw new UnsupportedOperationException(); + } + + @Override + public HmsDatabaseInfo getDatabase(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listTables(String dbName) { + throw new UnsupportedOperationException(); + } + + @Override + public Map getDefaultColumnValues(String dbName, String tableName) { + throw new UnsupportedOperationException(); + } + + @Override + public List listPartitionNames(String dbName, String tableName, int maxParts) { + throw new UnsupportedOperationException(); + } + + @Override + public List getPartitions(String dbName, String tableName, List partNames) { + throw new UnsupportedOperationException(); + } + + @Override + public HmsPartitionInfo getPartition(String dbName, String tableName, List values) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + } + } + + /** A client whose table lookup fails the way a dropped or unauthorized table does. */ + private static final class ThrowingHmsClient extends FakeHmsClient { + + ThrowingHmsClient() { + super(null); + } + + @Override + public HmsTableInfo getTable(String dbName, String tableName) { + throw new HmsClientException("table not found: " + dbName + "." + tableName); + } + } +} diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorScanProviderDivertTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorScanProviderDivertTest.java index 3a9d1adb18b30a..5bbbc61d9f26ef 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorScanProviderDivertTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorScanProviderDivertTest.java @@ -21,11 +21,10 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.DorisConnectorException; -import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.handle.ConnectorTableHandle; -import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -34,7 +33,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; /** * Pins the HMS-cutover §4.4 S5 scan-provider gateway seam: {@link HiveConnector#getScanPlanProvider( @@ -198,8 +196,7 @@ public void close() { /** A bare scan provider stand-in; only its identity matters here. */ private static final class MarkerScanProvider implements ConnectorScanPlanProvider { @Override - public List planScan(ConnectorSession session, ConnectorTableHandle handle, - List columns, Optional filter) { + public List planScan(ConnectorSession session, ConnectorScanRequest request) { return Collections.emptyList(); } } 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 0fe038139b41a4..f4135bbbd77cfd 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 @@ -22,15 +22,14 @@ 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; import org.apache.doris.connector.api.handle.ConnectorWriteHandle; import org.apache.doris.connector.api.procedure.ConnectorProcedureOps; import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; -import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.pushdown.ConnectorPredicate; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.api.write.ConnectorSinkPlan; import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; @@ -41,7 +40,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; /** * Pins the hms-cutover THREE-WAY foreign-handle routing: with TWO embedded siblings (iceberg + hudi) under one @@ -290,8 +288,7 @@ public void close() { private static final class MarkerScanProvider implements ConnectorScanPlanProvider { @Override - public List planScan(ConnectorSession session, ConnectorTableHandle handle, - List columns, Optional filter) { + public List planScan(ConnectorSession session, ConnectorScanRequest request) { return Collections.emptyList(); } } diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorTransactionTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorTransactionTest.java index ddf2e1a4163455..851db28f2d2915 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorTransactionTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorTransactionTest.java @@ -163,7 +163,7 @@ private static THivePartitionUpdate puWithMpu(String name, TUpdateMode mode, Str } // Builds a transaction whose engine FileSystem is the injected fake, borrowed via the context — mirroring - // production, where context.getFileSystem(session) hands back the per-catalog SpiSwitchingFileSystem. The + // production, where storage().getFileSystem(session) hands back the per-catalog SpiSwitchingFileSystem. The // concrete fake is wrapped in a non-ObjFileSystem routing facade so the connector MUST call forLocation(...) // to narrow to the ObjFileSystem: a regression that casts getFileSystem() directly then fails instanceof. private HiveConnectorTransaction newTxnWithFs(HmsClient client, FileSystem concreteFs) { diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveCreateTableValidationTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveCreateTableValidationTest.java index 12aad9f1ad8d53..108eb28654c212 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveCreateTableValidationTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveCreateTableValidationTest.java @@ -54,6 +54,14 @@ private ConnectorColumn col(String name, String type, boolean nullable) { return new ConnectorColumn(name, ConnectorType.of(type), "", nullable, null); } + /** + * Overload for complex types: {@link ConnectorType#of(String)} takes a scalar type name only, since a complex + * type must carry its child types (a bare "ARRAY" is rejected outright). + */ + private ConnectorColumn col(String name, ConnectorType type, boolean nullable) { + return new ConnectorColumn(name, type, "", nullable, null); + } + private ConnectorCreateTableRequest request(List columns) { return ConnectorCreateTableRequest.builder().dbName("db").tableName("t").columns(columns).build(); } @@ -102,7 +110,10 @@ public void floatingPointPartitionColumnIsRejected() { @Test public void complexPartitionColumnIsRejected() { - ConnectorCreateTableRequest request = request(Arrays.asList(col("id", "INT", true), col("a", "ARRAY", true))); + // A well-formed ARRAY: the point is that validatePartition rejects it for being COMPLEX, not that + // the type itself is malformed -- a childless "ARRAY" would fail construction and never reach the check. + ConnectorCreateTableRequest request = request(Arrays.asList(col("id", "INT", true), + col("a", ConnectorType.arrayOf(ConnectorType.of("INT")), true))); DorisConnectorException ex = Assertions.assertThrows(DorisConnectorException.class, () -> metadata().validatePartition(request, true, Collections.singletonList("a"))); Assertions.assertTrue(ex.getMessage().contains("Complex type column can't be partition column"), diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java index 45b1f301685c4f..5a397d90625139 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveFileListingCacheTest.java @@ -21,6 +21,7 @@ import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.hms.HmsPartitionInfo; import org.apache.doris.filesystem.FileEntry; import org.apache.doris.filesystem.FileSystem; @@ -37,7 +38,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; /** * Tests {@link HiveFileListingCache}: the connector-owned directory-listing cache (D2's second layer, separate @@ -455,9 +455,9 @@ public void scanSkipsOnlyTheFailedPartitionAndPlansTheRest() { new FakeConnectorContext(), new HiveReadTransactionManager(), new HiveFileListingCache(Collections.emptyMap(), lister)); - List ranges = provider.planScan( - new FakeSession(), twoPartitionHandle(), Collections.emptyList(), - Optional.empty()); + List ranges = provider.planScan(new FakeSession(), + ConnectorScanRequest.builder(twoPartitionHandle(), Collections.emptyList()) + .build()); // Both partitions were attempted; only the healthy one produced its (one-file, one-range) split. Assertions.assertEquals(1, (int) lister.callsPerLocation.get("loc/dt=1")); @@ -478,9 +478,9 @@ public void scanFailsLoudOnSystemicFilesystemFailure() { new FakeConnectorContext(), new HiveReadTransactionManager(), new HiveFileListingCache(Collections.emptyMap(), lister)); - Assertions.assertThrows(DorisConnectorException.class, () -> provider.planScan( - new FakeSession(), singlePartitionHandle(), Collections.emptyList(), - Optional.empty())); + Assertions.assertThrows(DorisConnectorException.class, () -> provider.planScan(new FakeSession(), + ConnectorScanRequest.builder(singlePartitionHandle(), Collections.emptyList()) + .build())); } private static HiveTableHandle singlePartitionHandle() { @@ -523,10 +523,12 @@ public void scanProviderServesRepeatedScansFromTheCache() { new HmsPartitionInfo(Collections.singletonList("2"), "loc/dt=2", null, null, null, null))) .build(); - List first = provider.planScan( - new FakeSession(), handle, Collections.emptyList(), Optional.empty()); - List second = provider.planScan( - new FakeSession(), handle, Collections.emptyList(), Optional.empty()); + List first = provider.planScan(new FakeSession(), + ConnectorScanRequest.builder(handle, Collections.emptyList()) + .build()); + List second = provider.planScan(new FakeSession(), + ConnectorScanRequest.builder(handle, Collections.emptyList()) + .build()); // WHY: each partition directory is listed exactly once across the two scans — the second scan is served // from the cache. Without the cache the file listing would be an uncached RPC/FS call on every scan. diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java index b987643bfce76f..02ea72e0018ff9 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.hms.HmsClient; import org.apache.doris.connector.hms.HmsDatabaseInfo; import org.apache.doris.connector.hms.HmsPartitionInfo; @@ -128,9 +129,9 @@ public void planScanForPartitionBatchResolvesOnlyTheBatch() { part("year=2024/month=02"))) .build(); - List ranges = provider.planScanForPartitionBatch( - new FakeSession(), handle, Collections.emptyList(), - Optional.empty(), -1L, Collections.singletonList("year=2024/month=01")); + List ranges = provider.planScanForPartitionBatch(new FakeSession(), + ConnectorScanRequest.builder(handle, Collections.emptyList()) + .build(), Collections.singletonList("year=2024/month=01")); // WHY (Rule 9): exactly one range for the single-partition batch. This fails (== 3) precisely if the batch // is not partition-scoped — the duplicate-splits bug the override exists to prevent. @@ -168,9 +169,9 @@ public String normalizeStorageUri(String rawUri) { .prunedPartitions(Collections.singletonList(part("year=2024/month=01"))) .build(); - List ranges = provider.planScanForPartitionBatch( - new FakeSession(), handle, Collections.emptyList(), - Optional.empty(), -1L, Collections.singletonList("year=2024/month=01")); + List ranges = provider.planScanForPartitionBatch(new FakeSession(), + ConnectorScanRequest.builder(handle, Collections.emptyList()) + .build(), Collections.singletonList("year=2024/month=01")); Assertions.assertEquals(1, ranges.size()); Assertions.assertEquals("s3://bucket/db/t/p/000000_0", diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangePartitionValuesTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangePartitionValuesTest.java index c40abc7c41d704..490b4daf980b11 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangePartitionValuesTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanRangePartitionValuesTest.java @@ -39,8 +39,8 @@ * source-specific string matching), so this connector now owns the sentinel->NULL mapping, mirroring * {@code IcebergScanRange}/{@code PaimonScanRange}. These tests pin: (a) real values pass through with * {@code is_null=false}; (b) the sentinel maps to {@code ""}+{@code is_null=true}; (c) a LITERAL - * {@code "\N"} is a genuine value, NOT null (i.e. we use the narrow {@code .equals}, not - * {@code ConnectorPartitionValues.normalize}); (d) an unpartitioned range emits nothing; (e) an ACID + * {@code "\N"} is a genuine value, NOT null (i.e. we use the narrow {@code .equals}, not hudi's wider + * directory-name rule); (d) an unpartitioned range emits nothing; (e) an ACID * range emits BOTH the transactional params and the partition values. The emitted keys are the * partition column names in {@code path_partition_keys} order, keeping the bytes identical to the * legacy engine-side path.

    @@ -94,8 +94,8 @@ public void testDefaultPartitionSentinelMapsToSqlNull() { @Test public void testLiteralBackslashNIsNotNull() { // A literal "\N" partition value is NOT the Hive default-partition sentinel and must survive as a - // real value (is_null=false). This is why the connector uses the narrow HIVE_DEFAULT_PARTITION.equals - // and NOT ConnectorPartitionValues.normalize (which would coerce "\N" to SQL NULL). + // real value (is_null=false). This is why the connector uses the narrow NULL_PARTITION_NAME.equals + // and NOT hudi's directory-name rule (HudiScanRange), which would coerce "\N" to SQL NULL. HiveScanRange range = HiveScanRange.builder() .path("/tbl/city=%5CN/000000_0") .partitionValues(orderedPartitionValues("city", "\\N")) diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java index fc9afd733d930f..825c6bf3c787e9 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveWritePlanProviderTest.java @@ -605,7 +605,7 @@ public boolean isOverwrite() { } @Override - public Map getWriteContext() { + public Map getStaticPartitionSpec() { return Collections.emptyMap(); } } diff --git a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/RecordingConnectorContext.java b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/RecordingConnectorContext.java index 9623155b9d4971..97983aeb26c1a7 100644 --- a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/RecordingConnectorContext.java +++ b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/RecordingConnectorContext.java @@ -19,6 +19,7 @@ import org.apache.doris.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.thrift.TFileType; @@ -34,7 +35,15 @@ * path / broker addresses / static storage creds that {@link HiveWritePlanProvider} consults, and passes * {@link #executeAuthenticated} through so the table load runs inside the (fake) auth context. */ -final class RecordingConnectorContext implements ConnectorContext { +final class RecordingConnectorContext implements ConnectorContext, ConnectorStorageContext { + + // Storage services moved onto ConnectorStorageContext; this double implements both halves and hands + // itself back, so its overrides below are the ones the connector reaches. Forgetting this getter would + // silently give the connector NOOP and make those overrides dead code. + @Override + public ConnectorStorageContext getStorageContext() { + return this; + } int authCount; diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java index d443fab976c140..391a322d1cdca5 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java @@ -389,11 +389,6 @@ private ConnectorTableSchema assembleTableSchema(HudiTableHandle hudiHandle, Lis hudiHandle.getTableName(), columns, "HUDI", tableProperties); } - @Override - public Map getProperties() { - return properties; - } - // ========== Read-only write-reject safety net ========== /** @@ -686,28 +681,9 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH return names; } - @Override - public List> listPartitionValues(ConnectorSession session, - ConnectorTableHandle handle, List partitionColumns) { - // partition_values() TVF (user-facing enumeration): list FRESH (bypassCache=true), like listPartitionNames. - List partitions = collectPartitions((HudiTableHandle) handle, true); - List> result = new ArrayList<>(partitions.size()); - for (ConnectorPartitionInfo partition : partitions) { - Map rawValues = partition.getPartitionValues(); - // Preserve the requested partitionColumns order (feeds the partition_values() TVF, whose inner-list - // order must match the input), mirroring PaimonConnectorMetadata.listPartitionValues. - List values = new ArrayList<>(partitionColumns.size()); - for (String column : partitionColumns) { - values.add(rawValues.get(column)); - } - result.add(values); - } - return result; - } - /** - * Shared partition collector backing {@link #listPartitions}, {@link #listPartitionNames} and - * {@link #listPartitionValues}. Lists the raw partition identifiers from the + * Shared partition collector backing {@link #listPartitions} and {@link #listPartitionNames}. Lists the + * raw partition identifiers from the * {@code use_hive_sync_partition}-aware source (mirroring legacy * {@code HudiExternalMetaCache.loadPartitionNames}), then renders one {@link ConnectorPartitionInfo} per * partition. Unpartitioned → {@code emptyList()} (legacy never lists partitions for an unpartitioned @@ -723,14 +699,15 @@ private List collectPartitions(HudiTableHandle handle, b // hmsClient, no metaClient), like legacy. The instant still comes from the timeline. If HMS has none // (a hive-sync table not yet synced), fall back to the hudi metadata listing (legacy parity). // bypassCache selects the freshness contract (mirrors HiveConnectorMetadata.collectPartitionNames): - // the SHOW-PARTITIONS / partition_values-TVF path (listPartitionNames / listPartitionValues) lists - // FRESH — legacy read the raw pooled client, so an externally hive-synced partition must not stay - // invisible until TTL/REFRESH — while the query-pruning / MTMV path (listPartitions) reads the cache. + // the SHOW PARTITIONS path (listPartitionNames) lists FRESH — legacy read the raw pooled client, so + // an externally hive-synced partition must not stay invisible until TTL/REFRESH — while the + // query-pruning / MTMV path (listPartitions) reads the cache. Note the partition_values() table + // function goes through listPartitions, so it reads the cache too and can trail it by one TTL. List hmsNames = bypassCache ? hmsClient.listPartitionNamesFresh(handle.getDbName(), handle.getTableName(), -1) : hmsClient.listPartitionNames(handle.getDbName(), handle.getTableName(), -1); if (hmsNames != null && !hmsNames.isEmpty()) { - return buildPartitionInfos(hmsNames, partKeyNames, latestInstant(handle)); + return buildPartitionInfos(hmsNames, partKeyNames, latestInstantMillis(handle)); } LOG.warn("hive-sync hudi table {}.{} has no HMS partitions; " + "falling back to hudi metadata partition listing", @@ -742,8 +719,13 @@ private List collectPartitions(HudiTableHandle handle, b Map.Entry> listing = metaClientExecutor.execute(() -> { HoodieTableMetaClient metaClient = HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), handle.getBasePath()); + // Convert to epoch millis HERE, on the metaClient we already built: the partition info's + // last-modified field is contractually epoch millis, and the timeline zone is only readable + // from the table config. Doing it here costs no extra remote call. return new AbstractMap.SimpleImmutableEntry<>( - HudiScanPlanProvider.latestCompletedInstant(metaClient), + HudiScanPlanProvider.instantToEpochMillis( + HudiScanPlanProvider.latestCompletedInstant(metaClient), + HudiScanPlanProvider.timelineZone(metaClient)), HudiScanPlanProvider.listAllPartitionPaths(metaClient)); }); return buildPartitionInfos(listing.getValue(), partKeyNames, listing.getKey()); @@ -752,12 +734,13 @@ private List collectPartitions(HudiTableHandle handle, b /** * Renders one {@link ConnectorPartitionInfo} per raw partition path. {@code partitionName} = hive-style * (for the fe-core re-parse), {@code partitionValues} = the unescaped value map (for the TVF), - * {@code lastModifiedMillis} = the pinned instant (a stable, monotonic freshness marker feeding - * {@code MTMVTimestampSnapshot}; row/size/file counts stay {@code UNKNOWN}). Static + package-private for - * offline unit testing. + * {@code lastModifiedMillis} = the pinned instant CONVERTED TO EPOCH MILLIS (a stable, monotonic + * freshness marker feeding {@code MTMVTimestampSnapshot}; row/size/file counts stay {@code UNKNOWN}). + * The conversion happens at the call sites, which hold the metaClient the timeline zone comes from; + * this method only passes the value through. Static + package-private for offline unit testing. */ static List buildPartitionInfos( - List rawPaths, List partKeyNames, long instant) { + List rawPaths, List partKeyNames, long lastModifiedMillis) { List result = new ArrayList<>(rawPaths.size()); for (String rawPath : rawPaths) { // Parse the unescaped values ONCE; render the hive-style name from the SAME values so the name and @@ -772,7 +755,7 @@ static List buildPartitionInfos( } result.add(new ConnectorPartitionInfo(name, values, Collections.emptyMap(), ConnectorPartitionInfo.UNKNOWN, ConnectorPartitionInfo.UNKNOWN, - instant, ConnectorPartitionInfo.UNKNOWN, + lastModifiedMillis, ConnectorPartitionInfo.UNKNOWN, orderedValues, Collections.emptyList())); } return result; @@ -788,6 +771,21 @@ long latestInstant(HudiTableHandle handle) { HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), handle.getBasePath()))); } + /** + * The same pin as {@link #latestInstant}, expressed in epoch millis for the partition info's + * last-modified field. Kept separate because the raw instant is what the snapshot id and time travel + * need - the two units must never be swapped for one another. + */ + long latestInstantMillis(HudiTableHandle handle) { + return metaClientExecutor.execute(() -> { + HoodieTableMetaClient metaClient = + HudiScanPlanProvider.buildMetaClient(buildHadoopConf(), handle.getBasePath()); + return HudiScanPlanProvider.instantToEpochMillis( + HudiScanPlanProvider.latestCompletedInstant(metaClient), + HudiScanPlanProvider.timelineZone(metaClient)); + }); + } + private boolean useHiveSyncPartition() { return Boolean.parseBoolean(properties.getOrDefault(USE_HIVE_SYNC_PARTITION, "false")); } diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorProvider.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorProvider.java index 993775d904c401..f63c19620fe377 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorProvider.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorProvider.java @@ -32,21 +32,31 @@ * {@code type=hudi} catalog and no {@code HudiExternalCatalog}: a hudi table is always parasitic on an HMS * catalog (legacy {@code HMSExternalTable} with {@code dlaType == HUDI}). After the HMS cutover this connector is * built only as an embedded sibling of the hive {@code hms} gateway, resolved through - * {@code ConnectorContext.createSiblingConnector("hudi", ...)} — which bypasses - * {@code CatalogFactory.SPI_READY_TYPES}. NEVER add {@code "hudi"} to {@code SPI_READY_TYPES} and never add - * a {@code case "hudi"} to the catalog factory: doing so would build a standalone - * {@code PluginDrivenExternalCatalog} around this connector with no fe-core catalog class backing it (the exact - * model mismatch this type string otherwise invites). + * {@code ConnectorContext.createSiblingConnector("hudi", ...)}. That is what {@link #isStandaloneCatalogType()} + * declares here: the engine asks every provider whether it may stand on its own as a catalog, and this one + * answers no. Never let it return {@code true} and never add a {@code case "hudi"} to the catalog + * factory: doing so would build a standalone {@code PluginDrivenExternalCatalog} around this connector with no + * fe-core catalog class backing it (the exact model mismatch this type string otherwise invites). Sibling + * lookup deliberately does not consult that declaration, so this connector stays reachable there. */ public class HudiConnectorProvider implements ConnectorProvider { @Override public String getType() { // Sibling-only lookup key for createSiblingConnector("hudi", ...); see the class javadoc. - // NOT a user-facing catalog type; never add "hudi" to CatalogFactory.SPI_READY_TYPES. + // NOT a user-facing catalog type — that is what isStandaloneCatalogType() below enforces. return "hudi"; } + /** + * Always {@code false}: there is no {@code type=hudi} catalog, so the engine must never build one around + * this connector. Sibling lookup is a separate entry point and is unaffected. See the class javadoc. + */ + @Override + public boolean isStandaloneCatalogType() { + return false; + } + @Override public Connector create(Map properties, ConnectorContext context) { return new HudiConnector(properties, context); diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java index d115722773e79a..aad2ce62399e17 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java @@ -24,7 +24,10 @@ import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.thrift.TFileCompressType; import org.apache.doris.thrift.TFileScanRangeParams; @@ -40,6 +43,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.timeline.HoodieInstant; +import org.apache.hudi.common.table.timeline.HoodieInstantTimeGenerator; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling; import org.apache.hudi.common.table.view.FileSystemViewManager; @@ -53,6 +57,10 @@ import org.apache.logging.log4j.Logger; import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -136,12 +144,16 @@ public TFileCompressType adjustFileCompressType(TFileCompressType inferred) { } @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - HudiTableHandle hudiHandle = (HudiTableHandle) handle; + public boolean supportsFileCache() { + // copy-on-write hudi tables are read by BE's native parquet reader. Declared explicitly because hudi + // tables live in an hms catalog: once governance follows the serving connector rather than the + // catalog type name, not declaring it here would silently drop hudi out of admission control. + return true; + } + + @Override + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + HudiTableHandle hudiHandle = (HudiTableHandle) request.getTableHandle(); String basePath = hudiHandle.getBasePath(); Configuration conf = buildHadoopConf(); @@ -313,13 +325,13 @@ public Map getScanNodeProperties( Map props = new LinkedHashMap<>(); // For COW tables, we default to parquet (may be overridden per-split). // For MOR tables, default is JNI. - props.put("file_format_type", isCow ? "parquet" : "jni"); + props.put(ScanNodePropertyKeys.FILE_FORMAT_TYPE, isCow ? "parquet" : "jni"); props.put("table_format_type", "hudi"); // Partition keys List partKeys = hudiHandle.getPartitionKeyNames(); if (partKeys != null && !partKeys.isEmpty()) { - props.put("path_partition_keys", String.join(",", partKeys)); + props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS, String.join(",", partKeys)); } // BE-facing storage for the native + JNI readers, mirroring legacy getLocationProperties' dual merge. @@ -328,7 +340,8 @@ public Map getScanNodeProperties( // 403s (the raw catalog aliases s3.access_key/... are useless to it). Sourced from the context's // single normalization hook. Empty for no context (offline tests) or a credential-less warehouse. if (context != null) { - context.getBackendStorageProperties().forEach((k, v) -> props.put("location." + k, v)); + storage().getBackendStorageProperties() + .forEach((k, v) -> props.put(ScanNodePropertyKeys.LOCATION_PREFIX + k, v)); } // (1b) Hadoop-canonical object-store config (fs.s3a.* / fs.oss.* / resolved hadoop.*/dfs.*) TRANSLATED // from the catalog's typed StorageProperties, for the Hudi JNI reader's own Hadoop FileSystem. @@ -338,7 +351,7 @@ public Map getScanNodeProperties( // getLocationProperties' merge that the raw passthrough (2) alone does not reconstruct (the catalog // carries s3. aliases, not fs.s3a. keys). Emitted BEFORE (2) so a user-inline fs./hadoop. key still // wins (mirrors buildHadoopConf precedence); null context yields an empty map (offline / HDFS-only). - storageHadoopConfig(context).forEach((k, v) -> props.put("location." + k, v)); + storageHadoopConfig(context).forEach((k, v) -> props.put(ScanNodePropertyKeys.LOCATION_PREFIX + k, v)); // (2) Hadoop-format passthrough for the Hudi JNI reader (its own Hadoop FileSystem: fs.s3a.* etc). // Emitted AFTER the canonical set so an overlapping hadoop key resolves to the catalog's explicit // value (legacy putAll order: backendStorageProperties then hadoopProperties). The s3./oss./cos./obs. @@ -349,7 +362,7 @@ public Map getScanNodeProperties( || key.startsWith("dfs.") || key.startsWith("hive.") || key.startsWith("s3.") || key.startsWith("cos.") || key.startsWith("oss.") || key.startsWith("obs.")) { - props.put("location." + key, entry.getValue()); + props.put(ScanNodePropertyKeys.LOCATION_PREFIX + key, entry.getValue()); } } @@ -413,7 +426,7 @@ public void populateScanLevelParams(TFileScanRangeParams params, Map requestedTime) { return requestedTime.map(Long::parseLong).orElse(0L); } + /** + * The zone hudi generated this table's instants in ({@code hoodie.table.timeline.timezone}, default + * {@code LOCAL}). An instant string carries no offset, so it can only be read back through the table's + * own declared zone. + * + *

    Read from the table config rather than through {@code HoodieInstantTimeGenerator}'s one-line + * parse helper on purpose: that helper takes the zone from a JVM-global static, which would put a + * whole timezone offset of error on a table that explicitly declares {@code UTC}. + */ + static ZoneId timelineZone(HoodieTableMetaClient metaClient) { + return metaClient.getTableConfig().getTimelineTimezone().getZoneId(); + } + + /** + * Converts a hudi instant ({@code yyyyMMddHHmmssSSS} read as a number) to epoch millis - the unit + * {@code ConnectorPartitionInfo#getLastModifiedMillis} requires. + * + *

    These are NOT interchangeable even though both are a {@code long}: an instant for 2024 reads as + * ~2.0e16 while the same moment in epoch millis is ~1.7e12, four orders of magnitude apart. The + * engine subtracts this field from the wall clock to decide whether a table has been quiet long + * enough to serve from the SQL cache, so an instant fed in there makes the difference come out as 0 + * forever and silently disables that cache for the table and everything queried alongside it. + * + *

    The raw instant keeps its own job (timeline lookup, snapshot id, time travel); only this + * freshness field changes unit. {@code instant <= 0} (empty timeline) maps to {@code 0}; anything + * unparseable logs and maps to {@code 0}, meaning "no reliable change signal" - the engine already + * degrades safely on that, and a statistics field must never fail a query on the scan hot path. + */ + static long instantToEpochMillis(long instant, ZoneId zone) { + if (instant <= 0) { + return 0L; + } + String instantTime = HoodieInstantTimeGenerator.fixInstantTimeCompatibility(String.valueOf(instant)); + try { + return LocalDateTime + .parse(instantTime, DateTimeFormatter.ofPattern( + HoodieInstantTimeGenerator.MILLIS_INSTANT_TIMESTAMP_FORMAT)) + .atZone(zone).toInstant().toEpochMilli(); + } catch (DateTimeParseException e) { + LOG.warn("Cannot read hudi instant {} as a timestamp; reporting no last-modified time", instant, e); + return 0L; + } + } + /** * Lists ALL partition relative paths from the Hudi metadata table (COW/MOR agnostic). Byte-faithful port of * legacy {@code HudiPartitionUtils.getAllPartitionNames}; extracted so both {@link #resolvePartitions} and @@ -936,9 +993,14 @@ private Configuration buildHadoopConf() { static Map storageHadoopConfig(ConnectorContext context) { Map merged = new HashMap<>(); if (context != null) { - context.getStorageProperties().forEach(sp -> + context.getStorageContext().getStorageProperties().forEach(sp -> sp.toHadoopProperties().ifPresent(h -> merged.putAll(h.toHadoopConfigurationMap()))); } return merged; } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanRange.java b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanRange.java index c18c2d866ae5ad..a3c954337dcb24 100644 --- a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanRange.java +++ b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanRange.java @@ -19,7 +19,6 @@ import org.apache.doris.connector.api.scan.ConnectorPartitionValues; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileFormatType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.THudiFileDesc; @@ -48,6 +47,10 @@ public class HudiScanRange implements ConnectorScanRange { private static final long serialVersionUID = 1L; + // How hudi spells a NULL partition value in columns_from_path. Byte-frozen: it is what this connector has + // always sent, and it is also accepted as an INPUT spelling (a "\N" partition directory means NULL too). + private static final String HUDI_NULL_PARTITION_VALUE = "\\N"; + private final String path; private final long start; private final long length; @@ -119,11 +122,6 @@ private HudiScanRange(Builder builder) { this.forceJni = builder.forceJni; } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Optional getPath() { return Optional.ofNullable(path); @@ -240,15 +238,28 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, if (partValues != null && !partValues.isEmpty()) { List pathKeys = new ArrayList<>(); List pathValues = new ArrayList<>(); + List pathIsNull = new ArrayList<>(); for (Map.Entry entry : partValues.entrySet()) { + // A hudi partition value is a DIRECTORY NAME (HudiScanPlanProvider.parsePartitionValues + // unescapes it out of the partition path), so three spellings all mean SQL NULL: the + // hive-canonical sentinel, the older text-table "\N", and — defensively — a Java null. + // This 3-way rule lives here rather than in the neutral module because it only holds for + // directory-name partitioning: hive narrows it (a hive column may hold "\N" as DATA) and + // paimon rejects it outright (its partition values are typed, so "\N" is ordinary data). + // Rendering: hudi emits "\N" for a NULL where hive/paimon/iceberg emit "" — BE ignores the + // string whenever the flag is set, but the bytes stay as they were (see + // HudiScanRangePartitionValuesTest). + String value = entry.getValue(); + boolean nullValue = value == null + || ConnectorPartitionValues.NULL_PARTITION_NAME.equals(value) + || HUDI_NULL_PARTITION_VALUE.equals(value); pathKeys.add(entry.getKey()); - pathValues.add(entry.getValue()); + pathValues.add(nullValue ? HUDI_NULL_PARTITION_VALUE : value); + pathIsNull.add(nullValue); } - ConnectorPartitionValues.Normalized normalized = - ConnectorPartitionValues.normalize(pathValues); rangeDesc.setColumnsFromPathKeys(pathKeys); - rangeDesc.setColumnsFromPath(normalized.getValues()); - rangeDesc.setColumnsFromPathIsNull(normalized.getIsNull()); + rangeDesc.setColumnsFromPath(pathValues); + rangeDesc.setColumnsFromPathIsNull(pathIsNull); } } diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiBackendDescriptorTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiBackendDescriptorTest.java index ce531bdb9f145b..592ad11be40818 100644 --- a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiBackendDescriptorTest.java +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiBackendDescriptorTest.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.hudi; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.FileSystemType; import org.apache.doris.filesystem.properties.HadoopStorageProperties; import org.apache.doris.filesystem.properties.StorageKind; @@ -133,6 +134,12 @@ public void adjustFileCompressTypeRemapsLz4FrameLikeLegacyInheritance() { } private static ConnectorContext contextWithBackendProps(Map backendProps) { + ConnectorStorageContext storage = new ConnectorStorageContext() { + @Override + public Map getBackendStorageProperties() { + return backendProps; + } + }; return new ConnectorContext() { @Override public String getCatalogName() { @@ -145,8 +152,8 @@ public long getCatalogId() { } @Override - public Map getBackendStorageProperties() { - return backendProps; + public ConnectorStorageContext getStorageContext() { + return storage; } }; } @@ -184,6 +191,12 @@ public Optional toHadoopProperties() { return Optional.of(() -> hadoopConf); } }; + ConnectorStorageContext storage = new ConnectorStorageContext() { + @Override + public List getStorageProperties() { + return Collections.singletonList(sp); + } + }; return new ConnectorContext() { @Override public String getCatalogName() { @@ -196,9 +209,19 @@ public long getCatalogId() { } @Override - public List getStorageProperties() { - return Collections.singletonList(sp); + public ConnectorStorageContext getStorageContext() { + return storage; } }; } + + @Test + public void beFileCacheAdmissionAppliesToHudiTables() { + // Hudi tables live in an HMS catalog, so they used to inherit BE file-cache admission governance from + // the catalog type name "hms". Now the serving connector declares it, and the heterogeneous gateway + // routes a hudi table to THIS provider — so without this declaration hudi tables would silently drop + // out of the user's admission rules. MUTATION: return false here -> red. + Assertions.assertTrue(new HudiScanPlanProvider(new HashMap<>(), null).supportsFileCache()); + } + } diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorHmsCacheTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorHmsCacheTest.java index 4efa41ce7c4755..c508d470d0e01b 100644 --- a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorHmsCacheTest.java +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorHmsCacheTest.java @@ -38,11 +38,16 @@ * {@code HiveConnector} so repeated queries against the same hudi-on-HMS table stop re-hitting the metastore. * Two behaviors carry correctness weight and are pinned here (Rule 9): *

      - *
    • Fresh vs cached split. The user-facing enumeration paths ({@code SHOW PARTITIONS} = - * {@code listPartitionNames}, {@code partition_values()} TVF = {@code listPartitionValues}) MUST list - * FRESH (bypass the cache), or an externally hive-synced partition stays invisible until the 24h TTL — - * a freshness regression the raw pre-cache client never had. The query-pruning / MTMV path - * ({@code listPartitions}) MUST use the cache (that is the whole point of wrapping).
    • + *
    • Fresh vs cached split. {@code SHOW PARTITIONS} ({@code listPartitionNames}) MUST list FRESH + * (bypass the cache), or an externally hive-synced partition stays invisible until the 24h TTL — a + * freshness regression the raw pre-cache client never had. The query-pruning / MTMV path + * ({@code listPartitions}) MUST use the cache (that is the whole point of wrapping). + *

      Note which path the {@code partition_values()} table function takes, because the earlier comment + * here had it wrong: the engine reaches the connector through {@code listPartitions} + * ({@code PluginDrivenExternalTable.getNameToPartitionValues}), i.e. the CACHED path, so its output can + * trail an external partition addition by one cache TTL. That is pre-existing behavior, not something + * this cache introduced, and deciding whether the table function should list fresh is a separate + * change — the mapping is recorded here so it does not get re-derived wrongly.

    • *
    • REFRESH flush. {@code invalidateTable}/{@code invalidateDb}/{@code invalidateAll} MUST flush the * {@link CachingHmsClient}, or REFRESH cannot clear the sibling's own cache (the gateway forwards REFRESH * to the sibling, so the flush must land on THIS connector's client).
    • @@ -77,16 +82,6 @@ public void showPartitionsPathListsFresh() { Assertions.assertEquals(0, hms.cachedCalls, "SHOW PARTITIONS must NOT read the cached listing"); } - @Test - public void partitionValuesTvfListsFresh() { - // partition_values() TVF is user-facing enumeration too -> fresh, like listPartitionNames. - FakeHmsClient hms = new FakeHmsClient(ONE_PARTITION); - HudiConnectorMetadata md = hiveSyncMetadata(hms); - md.listPartitionValues(null, partitioned(), YEAR_MONTH); - Assertions.assertEquals(1, hms.freshCalls, "partition_values() must list FRESH (bypass cache)"); - Assertions.assertEquals(0, hms.cachedCalls, "partition_values() must NOT read the cached listing"); - } - @Test public void queryPruningPathUsesCache() { // listPartitions backs query pruning / MTMV -> must use the cache (the wrap's intended win). diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java index a6693234d15006..4c50aa7cc907e6 100644 --- a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java @@ -28,6 +28,11 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; @@ -69,6 +74,69 @@ public void emptyTimelinePinsZero() { Assertions.assertEquals(0L, HudiScanPlanProvider.requestedTimeToInstant(Optional.empty())); } + // ── item 1b: instant → epoch millis (the unit the partition info contract requires) ──────────────── + + @Test + public void instantConvertsToEpochMillis() { + // 20240101120000000 read as a hudi instant is 2024-01-01T12:00:00.000; in UTC that is this + // epoch-millis value. The two representations of the same moment differ by four orders of + // magnitude (2.0e16 vs 1.7e12), which is exactly why swapping them goes unnoticed. + Assertions.assertEquals(1704110400000L, + HudiScanPlanProvider.instantToEpochMillis(20240101120000000L, ZoneOffset.UTC)); + Assertions.assertTrue( + HudiScanPlanProvider.instantToEpochMillis(20240101120000000L, ZoneOffset.UTC) < 1e13, + "an epoch-millis value must be ~1e12, not the instant's ~1e16"); + } + + @Test + public void legacySecondPrecisionInstantConverts() { + // 14-digit (second-precision) instants predate the millis format. Hudi back-fills them with + // ".999" (verified against hudi-common 1.0.2's fixInstantTimeCompatibility / DEFAULT_MILLIS_EXT), + // which is also what keeps the mapping order-preserving. + Assertions.assertEquals(1704110400999L, + HudiScanPlanProvider.instantToEpochMillis(20240101120000L, ZoneOffset.UTC)); + } + + @Test + public void emptyTimelineConvertsToZero() { + // 0 = "no completed instant". Stays 0 (>= 0) so the version-token filter still accepts it. + Assertions.assertEquals(0L, HudiScanPlanProvider.instantToEpochMillis(0L, ZoneOffset.UTC)); + } + + @Test + public void unparseableInstantDegradesToZeroWithoutThrowing() { + // This value only feeds cache/freshness heuristics, and it is computed on the partition-listing + // hot path. Failing a whole table's queries over a statistics field is the wrong trade; 0 means + // "no reliable change signal", which the engine already handles. + Assertions.assertEquals(0L, HudiScanPlanProvider.instantToEpochMillis(5L, ZoneOffset.UTC)); + } + + @Test + public void convertedValueReadsAsHowLongTheTableHasBeenQuiet() { + // THE point of this whole conversion, stated as an assertion. + // + // The engine gates the SQL cache on `now - lastModifiedMillis >= quiet window`, having first + // clamped `now` to at least lastModifiedMillis (a guard against FE/metadata clock skew). Feed it + // a raw instant (~2.0e16) and that clamp drags "now" along with it, so the difference is 0 + // FOREVER - not "0 until the window passes". Result: partitioned hudi tables never serve from the + // SQL cache, and because the gate takes the maximum across every table a query touches, ONE hudi + // table also blocks caching for anything joined to it. + // + // MUTATION: pass the raw instant through instead of converting -> this difference becomes a huge + // negative number -> red. + ZoneId zone = ZoneId.systemDefault(); + LocalDateTime anHourAgo = LocalDateTime.now(zone).minusHours(1); + long instant = Long.parseLong(anHourAgo.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"))); + + long quietForMillis = System.currentTimeMillis() + - HudiScanPlanProvider.instantToEpochMillis(instant, zone); + + Assertions.assertTrue(Math.abs(quietForMillis - Duration.ofHours(1).toMillis()) + < Duration.ofMinutes(5).toMillis(), + "wall clock minus this field must read as 'the table has been quiet ~1 hour', got " + + quietForMillis + " ms"); + } + // ── item 2: hive-style NAME rendering + arity round-trip ─────────────────────────────────────────── @Test @@ -136,17 +204,19 @@ public void distinctSlashValuedPartitionsDoNotCollide() { // ── item 3/4: buildPartitionInfos + listPartitions/Names/Values ──────────────────────────────────── @Test - public void buildPartitionInfosStampsInstantAndValues() { + public void buildPartitionInfosStampsLastModifiedAndValues() { + // The caller converts the instant to epoch millis; this method only passes it through. List infos = HudiConnectorMetadata.buildPartitionInfos( - Arrays.asList("2024/01", "2024/02"), YEAR_MONTH, 20240101120000000L); + Arrays.asList("2024/01", "2024/02"), YEAR_MONTH, 1704110400000L); Assertions.assertEquals(2, infos.size()); ConnectorPartitionInfo first = infos.get(0); Assertions.assertEquals("year=2024/month=01", first.getPartitionName()); Assertions.assertEquals("2024", first.getPartitionValues().get("year")); Assertions.assertEquals("01", first.getPartitionValues().get("month")); - // lastModifiedMillis == the instant (a stable non-negative marker), NOT the -1 UNKNOWN sentinel. - Assertions.assertEquals(20240101120000000L, first.getLastModifiedMillis()); + // lastModifiedMillis is carried through verbatim (a stable non-negative marker), NOT the -1 + // UNKNOWN sentinel. + Assertions.assertEquals(1704110400000L, first.getLastModifiedMillis()); Assertions.assertNotEquals(ConnectorPartitionInfo.UNKNOWN, first.getLastModifiedMillis()); // row/size/file counts stay UNKNOWN (not collected on the hot path). Assertions.assertEquals(ConnectorPartitionInfo.UNKNOWN, first.getRowCount()); @@ -171,16 +241,6 @@ public void listPartitionNamesMatchesListPartitions() { } } - @Test - public void listPartitionValuesProjectsRequestedColumnOrder() { - HudiConnectorMetadata md = metadata(false, - new RecordingHmsClient(null), stub(new AbstractMap.SimpleImmutableEntry<>( - 99L, Collections.singletonList("2024/01")))); - // Request in reversed order: inner list order must follow the input columns, not the storage order. - List> values = md.listPartitionValues(null, partitioned(), Arrays.asList("month", "year")); - Assertions.assertEquals(Collections.singletonList(Arrays.asList("01", "2024")), values); - } - @Test public void unpartitionedTableListsNothing() { HudiConnectorMetadata md = metadata(false, new RecordingHmsClient(null), diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorProviderTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorProviderTest.java new file mode 100644 index 00000000000000..7cc4733cc844fd --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorProviderTest.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.hudi; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** What this provider declares about itself to the engine. */ +public class HudiConnectorProviderTest { + + @Test + public void testHudiIsSiblingOnlyAndNeverAStandaloneCatalogType() { + HudiConnectorProvider provider = new HudiConnectorProvider(); + + Assertions.assertEquals("hudi", provider.getType(), + "the type string is the key the hive gateway passes to createSiblingConnector"); + Assertions.assertFalse(provider.isStandaloneCatalogType(), + "There is no type=hudi catalog and no fe-core catalog class for one: a hudi table is always " + + "parasitic on an HMS catalog and is served as an embedded sibling of the hms gateway. " + + "The engine builds a catalog for any registered type that declares itself standalone, " + + "so flipping this to true would let CREATE CATALOG build a hudi catalog with no engine-" + + "side semantics behind it. Sibling lookup does not consult this, so declaring false " + + "costs hudi nothing."); + } +} diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java index c206e30a70482e..0a5d47198ccbaf 100644 --- a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiReadOnlyWriteRejectTest.java @@ -73,7 +73,9 @@ public void noWriterSoDataWritesRejectedByAdmissionGate() { // admitted and mis-executed -> red. HudiConnector connector = connector(); Assertions.assertNull(connector.getWritePlanProvider(HUDI_HANDLE)); - Assertions.assertTrue(connector.supportedWriteOperations(HUDI_HANDLE).isEmpty()); + // The connector-level getter must agree: a null provider IS the "no write support" declaration, and + // every engine-side write gate is a null check against it. + Assertions.assertNull(connector.getWritePlanProvider()); } @Test diff --git a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiScanRangePartitionValuesTest.java b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiScanRangePartitionValuesTest.java new file mode 100644 index 00000000000000..5069c5f2b808e6 --- /dev/null +++ b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiScanRangePartitionValuesTest.java @@ -0,0 +1,121 @@ +// 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.hudi; + +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Pins the {@code columns_from_path} / {@code columns_from_path_is_null} bytes + * {@link HudiScanRange#populateRangeParams} sends to BE for every shape a hudi partition directory name can + * take. (Distinct from {@code HudiPartitionValuesTest}, which covers the earlier step — parsing a partition + * PATH into the name/value map this class consumes.) + * + *

      WHY these three inputs all mean SQL NULL: a hudi partition value is a directory name. Hive-style writers + * spell a NULL partition {@code __HIVE_DEFAULT_PARTITION__}, and a literal {@code \N} directory is the older + * text-table spelling of the same thing. That equivalence holds ONLY for directory-name partitioning, which is + * why it lives in the hudi connector and not in the neutral module: hive narrows it (no {@code \N} arm, because + * a hive column may legitimately contain the two characters as DATA) and paimon rejects it outright (its + * partition values are typed, so {@code \N} is ordinary string data). See the WHY comment on the block under + * test.

      + * + *

      WHY this must not drift: {@code docker/thirdparties/.../hudi/11_create_mtmv_tables.sql} creates a real + * fixture table partitioned on {@code region='__HIVE_DEFAULT_PARTITION__'}, and the rendered value plus its + * null flag are what BE fills the partition column from + * ({@code partition_column_filler.h#fill_partition_column_from_path_value}).

      + * + *

      MUTATION NOTES (each arm is separately killable):

      + *
        + *
      • render the raw value instead of the NULL spelling -> the sentinel row AND the java-null row of + * {@code columns_from_path} go red;
      • + *
      • drop the literal-{@code \N} arm of the null test -> {@code columns_from_path} stays GREEN (the value is + * already {@code \N} either way); only {@code columns_from_path_is_null} catches it. That column is the + * sole detector for that mutation — do not reduce this test to a value-only assertion;
      • + *
      • treat the empty string as NULL (e.g. an {@code isEmpty()} rewrite) -> the empty row goes red.
      • + *
      + */ +public class HudiScanRangePartitionValuesTest { + + @Test + public void pathPartitionValuesRenderEveryNullSpelling() { + // One range carrying all five shapes at once, so the assertions also pin keys <-> values <-> flags + // alignment (BE zips the three lists positionally). LinkedHashMap: the render walks entrySet(), so + // iteration order IS the emitted order. + Map partValues = new LinkedHashMap<>(); + partValues.put("d_plain", "2024-01-01"); + partValues.put("d_sentinel", "__HIVE_DEFAULT_PARTITION__"); + // Reachable in production: parsePartitionValues strips the "col=" prefix, so a "dt=" path fragment + // yields "". It is a real (empty) value, NOT null. + partValues.put("d_empty", ""); + partValues.put("d_literal_null", "\\N"); + // Defensive arm only: parsePartitionValues always puts an unescapePathName() result, never null. Kept + // because the render still branches on it, and a HashMap-built range (as here) can carry one. + partValues.put("d_java_null", null); + + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/base.parquet") + .fileFormat("parquet") + .fileSize(456L) + .partitionValues(partValues) + .build(); + + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(new TTableFormatFileDesc(), rangeDesc); + + Assertions.assertEquals( + Arrays.asList("d_plain", "d_sentinel", "d_empty", "d_literal_null", "d_java_null"), + rangeDesc.getColumnsFromPathKeys(), + "partition column names go to BE verbatim, in map order"); + Assertions.assertEquals( + Arrays.asList("2024-01-01", "\\N", "", "\\N", "\\N"), + rangeDesc.getColumnsFromPath(), + "hudi renders a NULL partition as \\N (hive/paimon/iceberg render \"\" — do not unify here)"); + Assertions.assertEquals( + Arrays.asList(false, true, false, true, true), + rangeDesc.getColumnsFromPathIsNull(), + "the flag, not the string, is what makes BE emit SQL NULL"); + } + + @Test + public void noPartitionValuesLeavesColumnsFromPathUnset() { + // An unpartitioned slice must not stamp empty lists: on a fresh range desc the three fields stay unset. + // NOTE this is an assertion about THIS method only — unlike hive and iceberg, hudi does not unset what + // the engine pre-filled, so in production an empty map leaves any pre-filled values in place. + HudiScanRange range = new HudiScanRange.Builder() + .path("s3://bucket/t/base.parquet") + .fileFormat("parquet") + .fileSize(456L) + .partitionValues(Collections.emptyMap()) + .build(); + + TFileRangeDesc rangeDesc = new TFileRangeDesc(); + range.populateRangeParams(new TTableFormatFileDesc(), rangeDesc); + + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathKeys(), "no keys for an unpartitioned slice"); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPath(), "no values for an unpartitioned slice"); + Assertions.assertFalse(rangeDesc.isSetColumnsFromPathIsNull(), "no flags for an unpartitioned slice"); + } +} 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 16f09143d38522..b4d2c8c61cc4e8 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 @@ -35,6 +35,7 @@ import org.apache.doris.connector.metastore.spi.JdbcDriverSupport; import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.properties.S3CompatibleFileSystemProperties; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.kerberos.HadoopAuthenticator; @@ -440,14 +441,14 @@ private ConnectorTestResult probeStorage(String catalogType) { * or the catalog has no static storage credentials to send — e.g. a REST catalog with vended ones). */ ConnectorTestResult probeStorageFromBackend(String location) { - Map backendProps = new HashMap<>(context.getBackendStorageProperties()); + Map backendProps = new HashMap<>(storage().getBackendStorageProperties()); if (backendProps.isEmpty()) { return null; } // BE reads the bucket out of this key and dereferences it unconditionally — never omit it. backendProps.put(BE_TEST_LOCATION, location); try { - context.testBackendStorageConnectivity(TStorageBackendType.S3.getValue(), backendProps); + storage().testBackendStorageConnectivity(TStorageBackendType.S3.getValue(), backendProps); return null; } catch (Exception e) { LOG.warn("Iceberg storage connectivity test failed on the compute node for catalog '{}'", @@ -808,8 +809,8 @@ public ConnectorProcedureOps getProcedureOps() { /** * Iceberg exposes point-in-time snapshots, so it declares {@code SUPPORTS_MVCC_SNAPSHOT} (the gate for the * generic {@code PluginDrivenMvccExternalTable}, which drives {@code beginQuerySnapshot}/{@code - * resolveTimeTravel}/{@code applySnapshot}). Inert pre-cutover — the capability is consumed only on the - * plugin-driven path, which iceberg does not use until it enters {@code SPI_READY_TYPES} (P6.6). + * resolveTimeTravel}/{@code applySnapshot}). The capability is consumed only on the plugin-driven path, + * which is how iceberg is served since its cutover. */ @Override public Set getCapabilities() { @@ -896,7 +897,7 @@ private Catalog createCatalog() { } Optional chosenS3 = - IcebergCatalogFactory.chooseS3Compatible(context.getStorageProperties()); + IcebergCatalogFactory.chooseS3Compatible(storage().getStorageProperties()); String catalogName = IcebergCatalogFactory.resolveCatalogName(properties, flavor, context.getCatalogName()); // s3tables is bespoke: it is NOT built via CatalogUtil.buildIcebergCatalog. Legacy @@ -1202,7 +1203,7 @@ static Map deriveHdfsDefaultFsFromWarehouse(String warehouse) { */ private Map buildStorageHadoopConfig() { Map merged = new HashMap<>(); - for (StorageProperties sp : context.getStorageProperties()) { + for (StorageProperties sp : storage().getStorageProperties()) { sp.toHadoopProperties().ifPresent(h -> merged.putAll(h.toHadoopConfigurationMap())); } return merged; @@ -1510,4 +1511,9 @@ public void close() throws IOException { sessionCatalogAdapter = null; } } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } 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 44b689bbf7af97..c383261b369b1b 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 @@ -46,6 +46,7 @@ import org.apache.doris.connector.cache.ConnectorMetadataCache; import org.apache.doris.connector.cache.ConnectorTableKey; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.thrift.THiveTable; import org.apache.doris.thrift.TIcebergTable; import org.apache.doris.thrift.TTableDescriptor; @@ -811,11 +812,6 @@ private static long computeRowCount(Snapshot snapshot) { return Long.parseLong(totalRecords) - Long.parseLong(positionDeletes); } - @Override - public Map getProperties() { - return properties; - } - /** * Builds the read-path Thrift descriptor for an iceberg plugin table, forking on the catalog type * exactly as legacy {@code IcebergExternalTable.toThrift} / {@code IcebergSysExternalTable.toThrift}: @@ -857,16 +853,6 @@ public TTableDescriptor buildTableDescriptor( // ========== DDL writes (B1): create/drop database + table ========== - /** - * Iceberg supports CREATE DATABASE (namespace). Declaring it lets {@code PluginDrivenExternalCatalog.createDb} - * consult the remote namespace existence for IF NOT EXISTS (the SPI default {@code false} would skip that - * check). Mirrors paimon. - */ - @Override - public boolean supportsCreateDatabase() { - return true; - } - /** * Creates an iceberg namespace, mirroring legacy {@code IcebergMetadataOps.performCreateDb}. Namespace * properties are only honored by an HMS catalog; for every other flavor a non-empty property map fails @@ -944,7 +930,7 @@ public void dropDatabase(ConnectorSession session, String dbName, boolean ifExis // Cleanup runs OUTSIDE the iceberg auth scope: it is engine-side (its own storage creds) and // best-effort (failures are swallowed by the engine), so it must never fail the completed drop. namespaceLocation.ifPresent(location -> - context.cleanupEmptyManagedLocation(location, Collections.emptyList())); + storage().cleanupEmptyManagedLocation(location, Collections.emptyList())); } /** @@ -1093,7 +1079,7 @@ public void dropTable(ConnectorSession session, ConnectorTableHandle handle) { + iceHandle.getDbName() + "." + iceHandle.getTableName() + ": " + e.getMessage(), e); } tableLocation.ifPresent(location -> - context.cleanupEmptyManagedLocation(location, IcebergSchemaBuilder.tableLocationChildDirs())); + storage().cleanupEmptyManagedLocation(location, IcebergSchemaBuilder.tableLocationChildDirs())); } /** @@ -1796,6 +1782,25 @@ public void validateStaticPartitionColumns(ConnectorSession session, ConnectorTa } } + // ========== Predicate pushdown ========== + + /** + * Iceberg accepts CAST-bearing predicates ({@code true}, the SPI default, stated here rather than + * inherited). + * + *

      This is a conscious acceptance of the risk the SPI documents, not a claim of safety: the residual + * predicate is converted by {@code IcebergPredicateConverter} and handed to the {@code TableScan}, so it + * prunes manifests and data files AT THE SOURCE, and the engine has already unwrapped the CAST — a + * comparison whose literal iceberg binds differently than Doris coerced it can skip files that hold + * matching rows, which BE cannot recover. It stays {@code true} for parity with the legacy + * {@code IcebergScanNode}, which pushed the same converted predicate; no defect has been observed. A + * connector added later should default to {@code false} instead (see the SPI javadoc).

      + */ + @Override + public boolean supportsCastPredicatePushdown(ConnectorSession session) { + return true; + } + // ========== B-2: partition enumeration (MTMV RANGE view + SHOW PARTITIONS) ========== /** @@ -2218,4 +2223,9 @@ private static int getFormatVersion(Table table) { } return formatVersion; } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java index 6deb9b492fd736..2f9e90b5a4d121 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnectorProvider.java @@ -25,6 +25,7 @@ import java.util.Collections; import java.util.Map; +import java.util.Set; /** * SPI entry point for the Iceberg connector plugin. @@ -46,6 +47,15 @@ public Connector create(Map properties, ConnectorContext context return new IcebergConnector(properties, context); } + /** + * {@code CREATE TABLE ... ENGINE=iceberg} keeps working; omitting the clause is equivalent. The engine + * keyword is legacy syntax the connector owns, not the catalog type and not the displayed engine name. + */ + @Override + public Set acceptedCreateTableEngineNames() { + return Collections.singleton("iceberg"); + } + /** * Validates catalog properties at CREATE CATALOG time via the shared metastore parsers (P6-T10): the * flavor is resolved from {@code iceberg.catalog.type} ({@link IcebergCatalogFactory#resolveFlavor}) and 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 58416097ec7213..60a5a88757d3ae 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 @@ -109,8 +109,8 @@ * O5-2 write constraint ({@link #applyWriteConstraint}, a neutral {@link ConnectorPredicate} converted lazily * at commit) ANDed with a commit-time identity-partition filter derived from the commit fragments.

      * - *

      Live since the iceberg SPI cutover. {@code iceberg} is in {@code SPI_READY_TYPES}, so - * plugin-driven iceberg writes route through this class ({@link #beginWrite} is wired by + *

      Live since the iceberg SPI cutover. An {@code iceberg} catalog is served by this connector + * plugin, so plugin-driven iceberg writes route through this class ({@link #beginWrite} is wired by * {@code planWrite}). The txn-id is the engine-allocated Doris global id, so the generic * {@code PluginDrivenTransactionManager} registers it in both the per-manager map and * {@code GlobalExternalTransactionInfoMgr} — no per-connector registration code is needed, mirroring diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java index ed5c1d2e2a4713..a1daff65192e09 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java @@ -23,6 +23,7 @@ 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.procedure.ConnectorRewriteStatistics; import org.apache.doris.connector.api.procedure.ProcedureExecutionMode; import org.apache.doris.connector.api.pushdown.ConnectorPredicate; import org.apache.doris.connector.iceberg.action.BaseIcebergAction; @@ -147,6 +148,21 @@ public List planRewrite(ConnectorSession session, Connect return planInAuthScope(handle, action, session); } + /** + * Renders the rewrite driver's statistics into {@code rewrite_data_files}' result row. Deliberately does + * NOT enter an authorization scope and does not touch the catalog: it is pure local formatting, and the + * engine also calls it on the "planned zero groups" path where no transaction exists. + */ + @Override + public ConnectorProcedureResult buildRewriteResult(String procedureName, + ConnectorRewriteStatistics statistics) { + if (!IcebergExecuteActionFactory.REWRITE_DATA_FILES.equalsIgnoreCase(procedureName)) { + // Only rewrite_data_files is DISTRIBUTED for iceberg; fail loud on a miswired caller. + throw new DorisConnectorException("Unsupported distributed iceberg procedure: " + procedureName); + } + return IcebergRewriteDataFilesAction.buildResult(statistics); + } + /** * Loads the table and runs the procedure body within ONE authenticated scope. The body's SDK * manipulation and remote {@code commit()} must run under the catalog's auth context (Kerberized 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 a1a67dd210c192..e7b664d747dad3 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 @@ -26,9 +26,12 @@ import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanProfile; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.api.scan.ConnectorSplitSource; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.doris.connector.cache.CacheSpec; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.kerberos.HadoopAuthenticator; import org.apache.doris.thrift.TFileFormatType; @@ -112,17 +115,14 @@ /** * {@link ConnectorScanPlanProvider} for Iceberg tables, mirroring the paimon connector's * {@code PaimonScanPlanProvider}. The generic, engine-neutral {@code PluginDrivenScanNode} drives split - * generation through this provider once iceberg is in {@code SPI_READY_TYPES} (P6.6 cutover). + * generation through this provider. * *

      P6.2-T01 (this task) is the skeleton: it wires the collaborators ({@code properties} / * {@link IcebergCatalogOps} seam / {@link ConnectorContext}) and pins the predicate-driven semantics * ({@link #ignorePartitionPruneShortCircuit()} = {@code true}). The real split planning — self-contained * predicate pushdown, {@code FileScanTask} enumeration, native-vs-JNI classification, merge-on-read * delete files (T04), COUNT(*) pushdown (T05; batch mode deferred, mirrors paimon), the field-id - * history-schema dictionary (T06), and vended credentials (T09) — lands across P6.2-T02..T09. Iceberg is NOT - * yet in {@code SPI_READY_TYPES}, so {@link #planScan} is not exercised at - * runtime this phase (iceberg queries still route to the legacy {@code IcebergScanNode}); the parity is - * verified by offline unit tests until the P6.6 cutover.

      + * history-schema dictionary (T06), and vended credentials (T09) — lands across P6.2-T02..T09.

      */ public class IcebergScanPlanProvider implements ConnectorScanPlanProvider { @@ -399,30 +399,20 @@ public ConnectorColumnCategory classifyColumn(String columnName) { } @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - return planScanInternal(session, handle, columns, filter, false); + public boolean supportsFileCache() { + // iceberg data files are read by BE's native parquet/orc readers, so the BE file cache applies. + return true; } /** - * COUNT(*)-pushdown-aware scan entry (FIX-COUNT-PUSHDOWN). The generic {@code PluginDrivenScanNode} - * forwards the no-grouping {@code COUNT(*)} signal here. {@code limit}/{@code requiredPartitions} are not - * consumed by the iceberg read path (it is predicate-driven; mirrors paimon, whose other overloads fold - * down to the 4-arg planScan). + * The scan entry. Of everything on the request, iceberg consumes the handle, the columns, the filter and + * the no-grouping {@code COUNT(*)} signal (FIX-COUNT-PUSHDOWN); the row limit and the pruned partition set + * are not consumed by the iceberg read path — it is predicate-driven (mirrors paimon). */ @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit, - List requiredPartitions, - boolean countPushdown) { - return planScanInternal(session, handle, columns, filter, countPushdown); + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + return planScanInternal(session, request.getTableHandle(), request.getColumns(), + request.getFilter(), request.isCountPushdown()); } /** @@ -1461,7 +1451,7 @@ private static TFileFormatType deleteFileFormat(FileFormat format) { * normalizer that preserves the raw path (paimon parity). */ UnaryOperator newUriNormalizer(Map vendedToken) { - return context != null ? context.newStorageUriNormalizer(vendedToken) : UnaryOperator.identity(); + return context != null ? storage().newStorageUriNormalizer(vendedToken) : UnaryOperator.identity(); } /** @@ -1551,7 +1541,7 @@ public Map getScanNodeProperties( // 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", + props.put(ScanNodePropertyKeys.FILE_FORMAT_TYPE, systemTable ? "jni" : IcebergWriterHelper.getFileFormat(table, TableIdentifier.of(iceHandle.getDbName(), iceHandle.getTableName()), formatCache) @@ -1569,7 +1559,7 @@ public Map getScanNodeProperties( if (!systemTable) { List partitionKeys = IcebergPartitionUtils.getIdentityPartitionColumns(table); if (!partitionKeys.isEmpty()) { - props.put("path_partition_keys", String.join(",", partitionKeys)); + props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS, String.join(",", partitionKeys)); } } // Static storage credentials (T09, all flavors): the catalog's bound fe-filesystem StorageProperties, @@ -1582,10 +1572,10 @@ public Map getScanNodeProperties( // overlay, just the vended one below. if (context != null) { Map backendStorageProps = new HashMap<>(); - for (StorageProperties sp : context.getStorageProperties()) { + for (StorageProperties sp : storage().getStorageProperties()) { sp.toBackendProperties().ifPresent(b -> backendStorageProps.putAll(b.toMap())); } - backendStorageProps.forEach((k, v) -> props.put("location." + k, v)); + backendStorageProps.forEach((k, v) -> props.put(ScanNodePropertyKeys.LOCATION_PREFIX + k, v)); } // Vended-credential overlay (T09, REST per-table token): the raw token is extracted from the live, // snapshot-pinned table's FileIO (gated on the catalog flag iceberg.rest.vended-credentials-enabled, @@ -1595,8 +1585,8 @@ public Map getScanNodeProperties( // table yields no vended token (flag off / non-REST -> empty -> no-op). if (context != null) { Map vendedBeProps = - context.vendStorageCredentials(extractVendedToken(table, restVendedCredentialsEnabled())); - vendedBeProps.forEach((k, v) -> props.put("location." + k, v)); + storage().vendStorageCredentials(extractVendedToken(table, restVendedCredentialsEnabled())); + vendedBeProps.forEach((k, v) -> props.put(ScanNodePropertyKeys.LOCATION_PREFIX + k, v)); } // Field-id schema dictionary (T06). Under a time-travel pin (T07, Option A): the query slots carry the // PINNED schema's names, but the generic node builds the column handles from the LATEST schema (the pin @@ -2433,4 +2423,9 @@ private Table resolveSysTable(ConnectorSession session, IcebergTableHandle handl catalogOpsResolver.apply(session).loadTable(handle.getDbName(), handle.getTableName()), MetadataTableType.from(handle.getSysTableName())); } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporter.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporter.java index 2d78369f8e9081..359f2a86c76339 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporter.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporter.java @@ -49,7 +49,11 @@ * never drains).

      */ public class IcebergScanProfileReporter implements MetricsReporter { - /** Profile group name — MUST equal fe-core {@code SummaryProfile.ICEBERG_SCAN_METRICS} (display ordering). */ + /** + * Profile group name. Connector-chosen and self-contained: the engine get-or-creates a profile child under + * this name, so it needs no prior registration in fe-core. It IS user-visible in the query profile, hence + * pinned by a test. + */ static final String GROUP_NAME = "Iceberg Scan Metrics"; private static final DecimalFormat BYTES_FORMAT = new DecimalFormat("0.000"); private static final long KB = 1024L; diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java index 574fc9e55dc146..8e27898b249af9 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanRange.java @@ -18,7 +18,6 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileFormatType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TIcebergDeleteFileDesc; @@ -46,8 +45,7 @@ * props), iceberg's carriers are strongly typed fields (its params are numeric), so {@link #getProperties()} * stays empty. T04 adds the typed merge-on-read {@link DeleteFile} carriers (position / equality / deletion * vector); T05 adds the COUNT(*)-pushdown row count ({@code pushDownRowCount} → {@code table_level_row_count}). - * The field-id history dictionary (T06, scan-level) lands later. Iceberg is not yet in - * {@code SPI_READY_TYPES}, so no range reaches BE.

      + * The field-id history dictionary (T06, scan-level) lands later.

      */ public class IcebergScanRange implements ConnectorScanRange { @@ -150,11 +148,6 @@ private IcebergScanRange(Builder builder) { this.positionDeleteContentSizeInBytes = builder.positionDeleteContentSizeInBytes; } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Optional getPath() { return Optional.ofNullable(path); diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java index fefcb8fe77d379..18d1445817713e 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWriteContext.java @@ -33,7 +33,7 @@ * SDK operation: AppendFiles / ReplacePartitions / OverwriteFiles / RowDelta). * *

      P6.3-T06 populates this from the {@code ConnectorWriteHandle} - * ({@code getWriteOperation}/{@code isOverwrite}/{@code getWriteContext}) in {@code planWrite}.

      + * ({@code getWriteOperation}/{@code isOverwrite}/{@code getStaticPartitionSpec}) in {@code planWrite}.

      */ final class IcebergWriteContext { 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 efa0dfdd691ca0..90144fc879b1e7 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 @@ -33,6 +33,7 @@ import org.apache.doris.connector.api.write.ConnectorWriteSortColumn; import org.apache.doris.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.thrift.TDataSink; import org.apache.doris.thrift.TDataSinkType; @@ -95,8 +96,8 @@ * per-statement {@link IcebergStatementScope} carried across the scan→write seam (commit-bridge S4 part 2), * replacing the legacy fe-resident rewritable-delete planner.

      * - *

      Live since the iceberg SPI cutover. {@code iceberg} is in {@code SPI_READY_TYPES}, so a - * plugin-driven iceberg write routes through this provider; {@link #planWrite} requires the executor-bound + *

      Live since the iceberg SPI cutover. An {@code iceberg} catalog is served by this connector + * plugin, so a plugin-driven iceberg write routes through this provider; {@link #planWrite} requires the executor-bound * connector transaction and fails loud if absent.

      */ public class IcebergWritePlanProvider implements ConnectorWritePlanProvider { @@ -372,7 +373,7 @@ private IcebergWriteContext buildWriteContext(ConnectorWriteHandle handle) { // Branch-targeted INSERT (INSERT INTO tbl@branch): the branch is threaded from the generic insert // command context onto the write handle; beginWrite validates it against the table refs and points // the commit at the branch. Empty for a default-ref write. - return new IcebergWriteContext(op, handle.isOverwrite(), handle.getWriteContext(), + return new IcebergWriteContext(op, handle.isOverwrite(), handle.getStaticPartitionSpec(), handle.getBranchName(), readSnapshotId); } @@ -420,8 +421,9 @@ private TIcebergTableSink buildSink(Table table, IcebergTableHandle tableHandle, // Overwrite + static partition values (INSERT OVERWRITE ... PARTITION). tSink.setOverwrite(handle.isOverwrite()); - if (handle.isOverwrite() && handle.getWriteContext() != null && !handle.getWriteContext().isEmpty()) { - tSink.setStaticPartitionValues(handle.getWriteContext()); + Map staticPartitionSpec = handle.getStaticPartitionSpec(); + if (handle.isOverwrite() && staticPartitionSpec != null && !staticPartitionSpec.isEmpty()) { + tSink.setStaticPartitionValues(staticPartitionSpec); } return tSink; } @@ -611,7 +613,7 @@ private LocationFields resolveLocationFields(Table table) { Map vendedToken = IcebergScanPlanProvider.extractVendedToken( table, IcebergScanPlanProvider.restVendedCredentialsEnabled(properties)); if (context != null) { - TFileType fileType = TFileType.valueOf(context.getBackendFileType(rawLocation, vendedToken)); + TFileType fileType = TFileType.valueOf(storage().getBackendFileType(rawLocation, vendedToken)); // A broker backend (ofs://, gfs:// -> FILE_BROKER) must also carry the broker addresses, or BE // gets a broker sink with an empty broker list and the write fails. Mirrors legacy // IcebergTableSink: resolve broker addresses only when fileType == FILE_BROKER (S3/HDFS/local @@ -619,7 +621,7 @@ private LocationFields resolveLocationFields(Table table) { List brokerAddresses = fileType == TFileType.FILE_BROKER ? resolveBrokerAddresses() : Collections.emptyList(); return new LocationFields(rawLocation, - context.normalizeStorageUri(rawLocation, vendedToken), fileType, brokerAddresses); + storage().normalizeStorageUri(rawLocation, vendedToken), fileType, brokerAddresses); } return new LocationFields(rawLocation, rawLocation, TFileType.FILE_S3, Collections.emptyList()); } @@ -632,7 +634,7 @@ private LocationFields resolveLocationFields(Table table) { * ships an empty broker list to BE. */ private List resolveBrokerAddresses() { - List addresses = context.getBrokerAddresses(); + List addresses = storage().getBrokerAddresses(); if (addresses.isEmpty()) { throw new DorisConnectorException("No alive broker."); } @@ -665,18 +667,18 @@ private Map buildHadoopConfig(Table table) { if (context != null) { // Static catalog credentials in BE-canonical form (AWS_* for object stores, dfs/hadoop for HDFS), // sourced from the typed fe-filesystem StorageProperties bound by the catalog and handed over via - // ctx.getStorageProperties(): each backend's toBackendProperties().toMap() yields the canonical map + // storage().getStorageProperties(): each backend's toBackendProperties().toMap() yields the canonical map // (design S3 — the write derives its BE creds from the SAME typed fe-filesystem source as the scan // path IcebergScanPlanProvider.getScanNodeProperties, retiring the redundant fe-core // getBackendStorageProperties() second parse). The BE S3 sink (s3_util.cpp // convert_properties_to_s3_conf) reads ONLY AWS_*, so the fs.s3a.* hadoop form (correct for the FE // iceberg-catalog Configuration) would leave the BE writer with no creds. - for (StorageProperties sp : context.getStorageProperties()) { + for (StorageProperties sp : storage().getStorageProperties()) { sp.toBackendProperties().ifPresent(b -> merged.putAll(b.toMap())); } // REST per-table vended overlay (colliding key takes the vended value — legacy/scan precedence): a // vending catalog's static storage map is empty by design, so the vended creds are the only ones. - merged.putAll(context.vendStorageCredentials( + merged.putAll(storage().vendStorageCredentials( IcebergScanPlanProvider.extractVendedToken( table, IcebergScanPlanProvider.restVendedCredentialsEnabled(properties)))); } @@ -789,4 +791,9 @@ private static String dataLocation(Table table) { } return dataLocation; } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } 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 380f77ec65b915..f9ae64ab49714d 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 @@ -17,25 +17,21 @@ package org.apache.doris.connector.iceberg; -import org.apache.doris.connector.api.Connector; -import org.apache.doris.connector.api.ConnectorHttpSecurityHook; -import org.apache.doris.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; -import org.apache.doris.connector.spi.ConnectorMetaInvalidator; -import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.connector.spi.ForwardingConnectorContext; import org.apache.doris.kerberos.HadoopAuthenticator; -import java.util.List; -import java.util.Map; 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 * classloader for the duration of every {@link #executeAuthenticated} call, then delegates to the wrapped - * engine context. Every other method is a pure pass-through. + * engine context. Every other method is forwarded verbatim by {@link ForwardingConnectorContext} - which + * is the point of extending it rather than implementing the interface and copying each method by hand: a + * missed pass-through would not fail to compile, it would quietly land on the interface default (a silent + * downgrade) instead of the engine. * *

      WHY: iceberg-aws builds its S3 client lazily on the FIRST remote output of a {@code commit()} * ({@code S3FileIO.newOutputFile} → {@code AwsClientFactories$DefaultAwsClientFactory.s3()} → @@ -71,15 +67,14 @@ * ({@code hadoopAuthenticator.doAs(task::call)}), so exception semantics are unchanged. When the supplier * returns {@code null} (non-Kerberos) the FE-injected path is preserved byte-for-byte. */ -final class TcclPinningConnectorContext implements ConnectorContext { +final class TcclPinningConnectorContext extends ForwardingConnectorContext { - private final ConnectorContext delegate; private final ClassLoader pluginClassLoader; private final Supplier pluginAuthenticator; TcclPinningConnectorContext(ConnectorContext delegate, ClassLoader pluginClassLoader, Supplier pluginAuthenticator) { - this.delegate = Objects.requireNonNull(delegate, "delegate"); + super(delegate); this.pluginClassLoader = Objects.requireNonNull(pluginClassLoader, "pluginClassLoader"); this.pluginAuthenticator = Objects.requireNonNull(pluginAuthenticator, "pluginAuthenticator"); } @@ -103,7 +98,7 @@ public T executeAuthenticated(Callable task) throws Exception { HadoopAuthenticator auth = pluginAuthenticator.get(); if (auth == null) { // Non-Kerberos: keep the FE-injected auth path exactly as-is. - return delegate.executeAuthenticated(task); + return delegate().executeAuthenticated(task); } // Kerberos: the connector is the sole authenticator. Run the op under the PLUGIN's UGI copy (the // one the plugin's FileSystem reads); do NOT also invoke the FE-injected app-side authenticator. @@ -113,99 +108,11 @@ public T executeAuthenticated(Callable task) throws Exception { } } - // ----- pure delegation ----- - - @Override - public String getCatalogName() { - return delegate.getCatalogName(); - } - - @Override - public long getCatalogId() { - return delegate.getCatalogId(); - } - - @Override - public Map getEnvironment() { - return delegate.getEnvironment(); - } - - @Override - public ConnectorHttpSecurityHook getHttpSecurityHook() { - return delegate.getHttpSecurityHook(); - } - - @Override - public String sanitizeJdbcUrl(String jdbcUrl) { - return delegate.sanitizeJdbcUrl(jdbcUrl); - } - - @Override - public ConnectorMetaInvalidator getMetaInvalidator() { - return delegate.getMetaInvalidator(); - } - - @Override - public Connector createSiblingConnector(String catalogType, Map properties) { - // Delegate to the raw engine context (not this wrapper): the sibling connector applies its OWN - // TCCL/auth pinning over the context it is handed, so it must receive the unwrapped context to avoid - // double-pinning to this plugin's loader. Keeps this decorator a true exhaustive pass-through. - return delegate.createSiblingConnector(catalogType, properties); - } - - @Override - public Map vendStorageCredentials(Map rawVendedCredentials) { - return delegate.vendStorageCredentials(rawVendedCredentials); - } - - @Override - public String normalizeStorageUri(String rawUri) { - return delegate.normalizeStorageUri(rawUri); - } - - @Override - public String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { - 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); - } - - @Override - public List getBrokerAddresses() { - return delegate.getBrokerAddresses(); - } - - @Override - public Map getBackendStorageProperties() { - return delegate.getBackendStorageProperties(); - } - - @Override - public List getStorageProperties() { - return delegate.getStorageProperties(); - } - - @Override - public void testBackendStorageConnectivity(int storageBackendTypeValue, - Map backendProperties) throws Exception { - // No TCCL pin: this runs entirely engine-side (backend registry + thrift), never in plugin code. - delegate.testBackendStorageConnectivity(storageBackendTypeValue, backendProperties); - } - - @Override - public void cleanupEmptyManagedLocation(String location, List tableChildDirs) { - delegate.cleanupEmptyManagedLocation(location, tableChildDirs); - } + // Every other method is forwarded by ForwardingConnectorContext. Only methods that must run under + // the plugin loader (or under the plugin's own authenticator) belong here. + // + // createSiblingConnector deliberately reaches the RAW engine context rather than this wrapper: the + // sibling applies its own TCCL/auth pinning to whatever context it is handed, so handing it a context + // already pinned to THIS plugin's loader would pin it to the wrong plugin. The base class forwards to + // the wrapped context, which is exactly that. } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesAction.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesAction.java index 3a468e1a54b67b..b3666328d396f2 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesAction.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesAction.java @@ -17,12 +17,17 @@ package org.apache.doris.connector.iceberg.action; +import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.DorisConnectorException; +import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; +import org.apache.doris.connector.api.procedure.ConnectorRewriteStatistics; import org.apache.doris.connector.api.pushdown.ConnectorPredicate; import org.apache.doris.connector.iceberg.rewrite.RewriteDataFilePlanner; import org.apache.doris.foundation.util.ArgumentParsers; +import com.google.common.collect.ImmutableList; import org.apache.iceberg.Table; import java.util.List; @@ -174,4 +179,49 @@ protected List executeAction(Table table, ConnectorSession session) { "rewrite_data_files is a distributed procedure and has no single-call body; " + "plan it via IcebergProcedureOps.planRewrite"); } + + /** + * The columns this procedure reports, moved here verbatim from the engine rewrite driver so the + * result shape lives with the procedure that produces it (the eight single-call siblings already + * declare theirs the same way). + * + *

      TWO TYPES LOOK WRONG AND ARE KEPT ON PURPOSE: {@code rewritten_bytes_count} is {@code INT} + * although it carries a {@code long} byte sum, and {@code removed_delete_files_count} is + * {@code BIGINT} although it carries an {@code int}. Both are the historical shape of this + * procedure's result set; changing either changes the column metadata every existing client sees, + * so it is a separate, deliberate decision and not a cleanup to make in passing.

      + * + *

      Static because {@link BaseIcebergAction} captures the schema from its constructor: an instance + * field would still be null at that point and the action would silently report no columns.

      + */ + private static final List RESULT_SCHEMA = ImmutableList.of( + new ConnectorColumn("rewritten_data_files_count", ConnectorType.of("INT"), + "Number of data which were re-written by this command", false, null), + new ConnectorColumn("added_data_files_count", ConnectorType.of("INT"), + "Number of new data files which were written by this command", false, null), + new ConnectorColumn("rewritten_bytes_count", ConnectorType.of("INT"), + "Number of bytes which were written by this command", false, null), + new ConnectorColumn("removed_delete_files_count", ConnectorType.of("BIGINT"), + "Number of delete files removed by this command", false, null)); + + @Override + protected List getResultSchema() { + // Unreachable today (this action has no single-call body), but it keeps the action + // self-describing like its siblings instead of declaring its columns only in buildResult. + return RESULT_SCHEMA; + } + + /** + * Renders what the engine-orchestrated rewrite did into this procedure's one result row. Pure local + * formatting: no table load, no remote call — the engine also calls this when it planned zero + * groups and therefore never opened a transaction. + */ + public static ConnectorProcedureResult buildResult(ConnectorRewriteStatistics statistics) { + List row = ImmutableList.of( + String.valueOf(statistics.getDataFileCount()), + String.valueOf(statistics.getAddedDataFileCount()), + String.valueOf(statistics.getTotalSizeBytes()), + String.valueOf(statistics.getDeleteFileCount())); + return new ConnectorProcedureResult(RESULT_SCHEMA, ImmutableList.of(row)); + } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlanner.java b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlanner.java index dbf177db075095..6437ab5563edb1 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlanner.java +++ b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/rewrite/RewriteDataFilePlanner.java @@ -61,8 +61,8 @@ * strictly all-or-nothing: any top-level conjunct that cannot be pushed to file pruning is a hard error * (the {@code size < countTopLevelConjuncts} guard below), never a silent widen of the rewrite scope. *
    - * The execution half ({@code RewriteDataFileExecutor} / {@code RewriteGroupTask} / the nereids INSERT-SELECT) - * stays in fe-core (P6.4-T06).

    + * The execution half ({@code ConnectorRewriteDriver} / {@code ConnectorRewriteGroupTask} / the nereids + * INSERT-SELECT) stays in fe-core (P6.4-T06).

    */ public class RewriteDataFilePlanner { private static final Logger LOG = LogManager.getLogger(RewriteDataFilePlanner.class); diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataDdlTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataDdlTest.java index 778ba27b0981fa..f14ab1ff6014e3 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataDdlTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorMetadataDdlTest.java @@ -61,12 +61,6 @@ private static IcebergConnectorMetadata metadata(RecordingIcebergCatalogOps ops, return new IcebergConnectorMetadata(ops, props(catalogType), ctx); } - @Test - public void testSupportsCreateDatabase() { - Assertions.assertTrue(metadata(new RecordingIcebergCatalogOps(), - new RecordingConnectorContext(), IcebergConnectorProperties.TYPE_REST).supportsCreateDatabase()); - } - // ---------- createDatabase ---------- @Test @@ -231,8 +225,7 @@ public void testCreateTableBuildsArtifactsAndCallsSeam() { new ConnectorColumn("name", ConnectorType.of("VARCHAR", 50, 0), "", true, null, false))) .partitionSpec(new ConnectorPartitionSpec(ConnectorPartitionSpec.Style.TRANSFORM, Collections.singletonList( - new ConnectorPartitionField("id", "bucket", Collections.singletonList(8))), - Collections.emptyList())) + new ConnectorPartitionField("id", "bucket", Collections.singletonList(8))))) .sortOrder(Collections.singletonList(new ConnectorSortField("id", true, true))) .build(); metadata(ops, ctx, IcebergConnectorProperties.TYPE_REST).createTable(null, request); diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java index b576b0c2813a0f..471739e28d17e6 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java @@ -23,6 +23,7 @@ import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import org.apache.doris.filesystem.properties.S3CompatibleFileSystemProperties; import org.apache.doris.filesystem.properties.StorageProperties; @@ -149,8 +150,7 @@ public void declaresMvccSnapshotCapability() { // WHY: SUPPORTS_MVCC_SNAPSHOT is the gate PluginDrivenExternalDatabase checks to build the MVCC/MTMV // table subclass (so beginQuerySnapshot/resolveTimeTravel/applySnapshot fire). MUTATION: leaving the // default empty capability set -> iceberg tables build as plain non-MVCC tables, time-travel silently - // reads latest -> red. (Inert pre-cutover; iceberg is not yet in SPI_READY_TYPES, so getCapabilities - // does not touch the catalog and needs no live connection.) + // reads latest -> red. (getCapabilities does not touch the catalog, so this needs no live connection.) IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); Set caps = connector.getCapabilities(); Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT)); @@ -176,8 +176,8 @@ public void declaresMetadataPreloadCapability() { // async pre-warms schema/snapshot before taking the read lock. Post-cutover PluginDrivenExternalTable // reproduces this ONLY when the connector declares SUPPORTS_METADATA_PRELOAD (replacing the legacy // engine-name "jdbc" gate). MUTATION: dropping the capability -> flipped iceberg degrades to synchronous - // bind-time metadata load (longer lock hold on slow metastores) -> red. (Inert pre-cutover; iceberg not - // yet in SPI_READY_TYPES, so getCapabilities does not touch the catalog.) + // bind-time metadata load (longer lock hold on slow metastores) -> red. (getCapabilities does not + // touch the catalog, so this needs no live connection.) IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); Assertions.assertTrue( connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_METADATA_PRELOAD), @@ -191,7 +191,7 @@ public void declaresColumnAutoAnalyzeAndTopNLazyMaterializeCapabilities() { // Post-cutover the generic fe-core gates reproduce both ONLY when the connector declares these // capabilities; omitting them silently regresses CBO stats quality (auto-analyze stalls) and Top-N // latency (lazy materialization skipped). MUTATION: dropping either capability -> the corresponding - // fe-core gate excludes flipped iceberg -> red. (Inert pre-cutover; iceberg not yet in SPI_READY_TYPES.) + // fe-core gate excludes flipped iceberg -> red. IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); Set caps = connector.getCapabilities(); Assertions.assertTrue(caps.contains(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE), @@ -239,7 +239,6 @@ public void declaresNestedColumnPruneCapability() { // is correct only because parseSchema/IcebergTypeMapping also carry the per-field ids the BE field-id // scan path matches nested leaves by. MUTATION: dropping the capability -> flipped iceberg stops pruning // STRUCT/ARRAY/MAP sub-fields (reads the whole complex column = read amplification) -> regression. - // (Inert pre-cutover; iceberg not yet in SPI_READY_TYPES.) IcebergConnector connector = new IcebergConnector(Collections.emptyMap(), new RecordingConnectorContext()); Assertions.assertTrue(connector.getCapabilities() .contains(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE), @@ -354,16 +353,18 @@ public void declaredWriteCapabilitiesMatchAndPassContractValidator() throws Exce catalog.initialize("test", Collections.emptyMap()); IcebergConnector connector = connectorWithCatalog(Collections.emptyMap(), catalog); + ConnectorWritePlanProvider writeProvider = connector.getWritePlanProvider(); + Assertions.assertNotNull(writeProvider, "iceberg connector must expose a write plan provider"); Assertions.assertEquals( EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE, WriteOperation.DELETE, WriteOperation.MERGE, WriteOperation.REWRITE), - connector.supportedWriteOperations()); - Assertions.assertTrue(connector.supportsWriteBranch()); - Assertions.assertTrue(connector.requiresParallelWrite()); - Assertions.assertFalse(connector.requiresPartitionLocalSort(), + writeProvider.supportedOperations()); + Assertions.assertTrue(writeProvider.supportsWriteBranch()); + Assertions.assertTrue(writeProvider.requiresParallelWrite()); + Assertions.assertFalse(writeProvider.requiresPartitionLocalSort(), "iceberg does NOT require partition-local sort (unlike MaxCompute)"); - Assertions.assertTrue(connector.requiresFullSchemaWriteOrder()); - Assertions.assertTrue(connector.requiresMaterializeStaticPartitionValues()); + Assertions.assertTrue(writeProvider.requiresFullSchemaWriteOrder()); + Assertions.assertTrue(writeProvider.requiresMaterializeStaticPartitionValues()); ConnectorContractValidator.validate(connector, "iceberg"); } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTestConnectionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTestConnectionTest.java index 66847a1d182cd0..abba24d84c1dc0 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTestConnectionTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTestConnectionTest.java @@ -19,6 +19,7 @@ import org.apache.doris.connector.api.ConnectorTestResult; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -130,6 +131,19 @@ public void metaFailureMessageTagsTheCatalogType() { @Test public void backendProbeCarriesTestLocationAndBackendCredentials() throws Exception { Map captured = new HashMap<>(); + ConnectorStorageContext storage = new ConnectorStorageContext() { + @Override + public Map getBackendStorageProperties() { + Map beProps = new HashMap<>(); + beProps.put("AWS_ACCESS_KEY", "ak"); + return beProps; + } + + @Override + public void testBackendStorageConnectivity(int type, Map props) { + captured.putAll(props); + } + }; ConnectorContext ctx = new ConnectorContext() { @Override public String getCatalogName() { @@ -142,15 +156,8 @@ public long getCatalogId() { } @Override - public Map getBackendStorageProperties() { - Map beProps = new HashMap<>(); - beProps.put("AWS_ACCESS_KEY", "ak"); - return beProps; - } - - @Override - public void testBackendStorageConnectivity(int type, Map props) { - captured.putAll(props); + public ConnectorStorageContext getStorageContext() { + return storage; } }; try (IcebergConnector connector = new IcebergConnector(new HashMap<>(), ctx)) { @@ -163,6 +170,19 @@ public void testBackendStorageConnectivity(int type, Map props) /** A backend that rejects the location must fail the DDL, tagged so the operator knows it is BE-side. */ @Test public void backendProbeFailureIsTaggedAsComputeNode() throws Exception { + ConnectorStorageContext storage = new ConnectorStorageContext() { + @Override + public Map getBackendStorageProperties() { + Map beProps = new HashMap<>(); + beProps.put("AWS_ACCESS_KEY", "ak"); + return beProps; + } + + @Override + public void testBackendStorageConnectivity(int type, Map props) throws Exception { + throw new Exception("Access Denied"); + } + }; ConnectorContext ctx = new ConnectorContext() { @Override public String getCatalogName() { @@ -175,15 +195,8 @@ public long getCatalogId() { } @Override - public Map getBackendStorageProperties() { - Map beProps = new HashMap<>(); - beProps.put("AWS_ACCESS_KEY", "ak"); - return beProps; - } - - @Override - public void testBackendStorageConnectivity(int type, Map props) throws Exception { - throw new Exception("Access Denied"); + public ConnectorStorageContext getStorageContext() { + return storage; } }; try (IcebergConnector connector = new IcebergConnector(new HashMap<>(), ctx)) { diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java index a8ee5463517849..d2fa6cf9221b77 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java @@ -17,10 +17,12 @@ package org.apache.doris.connector.iceberg; +import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.procedure.ConnectorProcedureResult; import org.apache.doris.connector.api.procedure.ConnectorRewriteGroup; +import org.apache.doris.connector.api.procedure.ConnectorRewriteStatistics; import org.apache.doris.connector.api.procedure.ProcedureExecutionMode; import com.google.common.collect.ImmutableList; @@ -42,6 +44,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * Pins the {@link IcebergProcedureOps} dispatch (P6.4-T03 skeleton + T04 bodies). @@ -56,9 +59,9 @@ *

    Cache invalidation is the engine's responsibility (H-6 fix): the dispatch must NOT invalidate any * cache — after the procedure returns, the engine ({@code ConnectorExecuteAction}) refreshes the mutated table * through the standard refresh-table path, the only path that drops both the engine meta cache (LOCAL-name - * keyed) and the connector's own per-table cache (REMOTE-name keyed). So {@code ctx.invalidatedTables} stays - * empty after every dispatch (success or failure); a non-empty list here would mean the removed connector-side - * notification was re-introduced.

    + * keyed) and the connector's own per-table cache (REMOTE-name keyed). This used to be asserted here through a + * recording context; it is now guaranteed structurally, because the connector-to-engine invalidation SPI no + * longer exists — there is nothing for a dispatch to call.

    */ public class IcebergProcedureOpsTest { @@ -160,11 +163,9 @@ public void planRewriteGroupsBinPackedFilesWithRawPaths() { Assertions.assertEquals(3, g.getDataFileCount()); Assertions.assertEquals(3 * 1024L, g.getTotalSizeBytes()); Assertions.assertEquals(0, g.getDeleteFileCount()); - // WHY: manifest reads run under the catalog auth context (Kerberos), like the procedure bodies; planning - // does NOT mutate the table, so it must not invalidate the cache. MUTATION: planning outside auth scope - // -> authCount 0 -> red; invalidating after planning -> invalidatedTables non-empty -> red. + // WHY: manifest reads run under the catalog auth context (Kerberos), like the procedure bodies. + // MUTATION: planning outside auth scope -> authCount 0 -> red. Assertions.assertEquals(1, ctx.authCount, "planning runs in one auth scope"); - Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), "planning must not invalidate the table"); } @Test @@ -217,6 +218,56 @@ public void planRewriteRejectsNonRewriteProcedure() { e.getMessage()); } + @Test + public void buildRewriteResultDeclaresTheResultShape() { + // WHY this test carries the whole guarantee: the result columns used to be hardcoded in the engine + // rewrite driver, and the end-to-end suites read the row by INDEX only — nothing anywhere asserts the + // column names or types. If this moves wrong, clients see renamed or re-typed columns and no other + // test notices. + // + // The four inputs are deliberately DISTINCT so any transposition is caught: an assertion built from + // zeros or repeated values cannot tell "added" from "rewritten", or bytes from delete files. + ConnectorProcedureResult result = newOps().buildRewriteResult("rewrite_data_files", + new ConnectorRewriteStatistics(3, 2, 4096L, 1)); + + Assertions.assertEquals( + ImmutableList.of("rewritten_data_files_count", "added_data_files_count", + "rewritten_bytes_count", "removed_delete_files_count"), + result.getResultSchema().stream().map(ConnectorColumn::getName).collect(Collectors.toList())); + // The 3rd column is INT although it carries a long byte count, and the 4th is BIGINT although it + // carries an int. Both are the historical shape of this result set and are kept ON PURPOSE — seeing + // them and "fixing" them changes the column metadata every client already sees. + Assertions.assertEquals( + ImmutableList.of("INT", "INT", "INT", "BIGINT"), + result.getResultSchema().stream().map(c -> c.getType().getTypeName()) + .collect(Collectors.toList())); + Assertions.assertEquals(ImmutableList.of(ImmutableList.of("3", "2", "4096", "1")), result.getRows()); + } + + @Test + public void buildRewriteResultRejectsNonDistributedProcedure() { + // Mirrors planRewrite's guard: only rewrite_data_files is DISTRIBUTED, so a miswired caller must fail + // loud rather than get rewrite columns back for some other procedure. + DorisConnectorException e = Assertions.assertThrows(DorisConnectorException.class, + () -> newOps().buildRewriteResult("rollback_to_snapshot", + new ConnectorRewriteStatistics(0, 0, 0L, 0))); + Assertions.assertTrue(e.getMessage().contains("Unsupported distributed iceberg procedure"), + e.getMessage()); + } + + @Test + public void buildRewriteResultTouchesNoCatalog() { + // Contract: rendering is purely local. The engine calls it on the "planned zero groups" path, where it + // deliberately never opened a transaction — so there is no authorization scope to make a remote call + // in. MUTATION: routing this through runInAuthScope/loadTable -> the log is non-empty -> red. + RecordingIcebergCatalogOps ops = new RecordingIcebergCatalogOps(); + IcebergProcedureOps procOps = new IcebergProcedureOps(Collections.emptyMap(), ops, null); + + procOps.buildRewriteResult("rewrite_data_files", new ConnectorRewriteStatistics(0, 0, 0L, 0)); + + Assertions.assertTrue(ops.log.isEmpty(), "rendering the result row must not touch the catalog"); + } + // ─────────────────── catalog-backed dispatch (T04) ─────────────────── @Test @@ -237,10 +288,6 @@ public void runsBodyInOneAuthScopeAndDoesNotInvalidateAtDispatch() { Assertions.assertEquals(1, ctx.authCount, "the body (load + SDK mutation + commit) runs in ONE auth scope"); Assertions.assertTrue(ops.log.contains("loadTable:db1.t")); - // H-6: the connector dispatch must NOT invalidate — the engine refreshes the table after this returns. - // A non-empty list would mean the removed connector-side getMetaInvalidator notification was re-added. - Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), - "the connector dispatch must not invalidate any cache (the engine owns invalidation)"); Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap1)), result.getRows().get(0)); Assertions.assertEquals(snap1, catalog.loadTable(id).currentSnapshot().snapshotId()); @@ -268,8 +315,6 @@ public void threadsSessionToTimestampBody() { Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap1)), result.getRows().get(0)); - Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), - "the connector dispatch must not invalidate any cache (the engine owns invalidation)"); } @Test @@ -287,8 +332,6 @@ public void failedAuthSurfacesAndDoesNotInvalidate() { Assertions.assertThrows(DorisConnectorException.class, () -> procOps.execute(SESSION, new IcebergTableHandle("db1", "t"), "rollback_to_snapshot", ImmutableMap.of("snapshot_id", String.valueOf(snap1)), null, Collections.emptyList())); - Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), - "a failed auth (body never ran) must not invalidate the table cache"); } @Test @@ -321,7 +364,6 @@ public void wrapsLoadTableFailure() { ImmutableMap.of("snapshot_id", "1"), null, Collections.emptyList())); Assertions.assertEquals( "Failed to load iceberg table db1.t: simulated loadTable failure for db1.t", e.getMessage()); - Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), "a load failure must not invalidate"); } @Test @@ -346,8 +388,6 @@ public void rollbackToCurrentSnapshotShortCircuitsWithoutCommit() { Assertions.assertEquals(ImmutableList.of(String.valueOf(snap2), String.valueOf(snap2)), result.getRows().get(0)); Assertions.assertEquals(historyBefore, catalog.loadTable(id).history().size(), "short-circuit: no commit"); - Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), - "the connector dispatch must not invalidate any cache (the engine owns invalidation)"); } @Test @@ -367,8 +407,6 @@ public void bodyFailureAfterSuccessfulLoadDoesNotInvalidate() { ImmutableMap.of("snapshot_id", "999999999"), null, Collections.emptyList())); Assertions.assertEquals("Snapshot 999999999 not found in table " + ops.table.name(), e.getMessage()); Assertions.assertEquals(1, ctx.authCount, "the load ran under auth (body executed, then threw)"); - Assertions.assertTrue(ctx.invalidatedTables.isEmpty(), - "a body failure after a successful load must not invalidate"); } private static InMemoryCatalog tableWithThreeSmallFiles() { diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderKerberosScanIoTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderKerberosScanIoTest.java index 43cda4adb9e9c9..2296fe927414d9 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderKerberosScanIoTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderKerberosScanIoTest.java @@ -18,6 +18,7 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.kerberos.HadoopAuthenticator; import org.apache.hadoop.security.UserGroupInformation; @@ -38,7 +39,6 @@ import java.security.PrivilegedExceptionAction; import java.util.Collections; import java.util.List; -import java.util.Optional; /** * Guards the Kerberos scan-planning seam (the FOURTH plugin-side UGI doAs locus, after DDL / @@ -117,8 +117,9 @@ public void kerberosPlanScanRoutesManifestReadsThroughPluginDoAs() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); // MUTATION: dropping wrapTableForScan from resolveTable leaves exactly ONE doAs (the loadTable wrap) @@ -140,8 +141,9 @@ public void nonKerberosPlanScanKeepsDelegatePathAndNoWrap() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); Assertions.assertEquals(1, delegate.authCount, @@ -188,10 +190,11 @@ public void sysTablePlanningRunsInsideAuthScope() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); - List ranges = provider.planScan( - null, - IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1, null, -1), - Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1, null, -1), + Collections.emptyList()) + .build()); Assertions.assertFalse(ranges.isEmpty(), "one-snapshot table must plan $snapshots tasks"); // MUTATION: dropping the planSystemTableScan thread-level wrap (whose ONE scope spans the base-table 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 1664758d87737c..1bccce8503989a 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 @@ -26,7 +26,7 @@ import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.pushdown.ConnectorLiteral; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.api.scan.ConnectorSplitSource; import org.apache.doris.filesystem.FileSystemType; import org.apache.doris.filesystem.properties.BackendStorageKind; @@ -167,14 +167,6 @@ private static ConnectorExpression eqInt(String col, int value) { // --- T01 capability + seam/auth tests (unchanged contract; now backed by a real empty table) --- - @Test - public void getScanRangeTypeIsFileScan() { - IcebergScanPlanProvider provider = - new IcebergScanPlanProvider(Collections.emptyMap(), new RecordingIcebergCatalogOps()); - // WHY: iceberg is file-based, so BE must build a TFileScanRange. MUTATION: JDBC_SCAN / CUSTOM -> red. - Assertions.assertEquals(ConnectorScanRangeType.FILE_SCAN, provider.getScanRangeType()); - } - @Test public void ignorePartitionPruneShortCircuitIsTrue() { IcebergScanPlanProvider provider = @@ -205,8 +197,9 @@ public void planScanResolvesTableViaSeamAndEmptyTableReturnsNoSplits() { RecordingIcebergCatalogOps ops = opsReturning(createTable("t1", SCHEMA, PartitionSpec.unpartitioned())); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertTrue(ranges.isEmpty()); Assertions.assertEquals("db1", ops.lastLoadDb); @@ -222,8 +215,9 @@ public void planScanResolvesTableInsideAuthContext() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops, context); - provider.planScan(null, new IcebergTableHandle("db1", "t1"), - Collections.emptyList(), Optional.empty()); + provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); // MUTATION: resolving the table OUTSIDE the auth wrap -> authCount stays 0 -> red. Assertions.assertEquals(1, context.authCount); @@ -247,7 +241,7 @@ public void planningPassLoadsSameTableOnceViaSharedScope() { .withScope(new TestStatementScope()); metadata.getColumnHandles(session, handle); - provider.planScan(session, handle, Collections.emptyList(), Optional.empty()); + provider.planScan(session, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); long remoteLoads = ops.log.stream().filter("loadTable:db1.t1"::equals).count(); Assertions.assertEquals(1, remoteLoads, @@ -267,7 +261,7 @@ public void planningPassWithoutSharedScopeLoadsEachTime() { ConnectorSession none = new FakeScanSession("UTC", Collections.emptyMap()); metadata.getColumnHandles(none, handle); - provider.planScan(none, handle, Collections.emptyList(), Optional.empty()); + provider.planScan(none, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); long remoteLoads = ops.log.stream().filter("loadTable:db1.t1"::equals).count(); Assertions.assertEquals(2, remoteLoads, "under NONE each resolver loads (no memo)"); @@ -284,8 +278,9 @@ public void planScanEnumeratesOneRangePerDataFile() { .commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); // Small files (< target split size) are not sub-split: one range per data file carrying the file's // path / size and the whole-file byte range. MUTATION: returning emptyList (T01 skeleton) -> red. @@ -314,8 +309,9 @@ public void planScanRewriteFileScopeKeepsOnlyRawScopedFiles() { // A rewrite group bin-packed to f1 + f3 only; f2 must be dropped. IcebergTableHandle scoped = new IcebergTableHandle("db1", "t1").withRewriteFileScope( ImmutableSet.of("oss://b/db/t1/f1.parquet", "oss://b/db/t1/f3.parquet")); - List ranges = provider.planScan( - null, scoped, Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(scoped, Collections.emptyList()) + .build()); // WHY: each rewrite group's INSERT-SELECT must scan EXACTLY its bin-packed files, identified by the raw // iceberg path. MUTATION: dropping the `scope != null && !scope.contains(...)` guard -> f2 leaks in @@ -345,8 +341,9 @@ public void planScanNullRewriteScopeReadsAllFiles() { // WHY: a bare handle (no rewrite scope) is EVERY normal scan; it must read all files, not zero. MUTATION: // the scope guard firing on a null scope (e.g. `scope.isEmpty()` instead of `scope != null`) -> 0 ranges // -> red. - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(2, ranges.size()); } @@ -364,8 +361,9 @@ public void planScanSplitsLargeFileByFileSplitSizeSessionVar() { ConnectorSession session = new FakeScanSession("UTC", Collections.singletonMap("file_split_size", Long.toString(32 * mb))); - List ranges = provider.planScan( - session, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(session, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); // The iceberg SDK TableScanUtil tiles the file into contiguous byte ranges covering the whole file. // MUTATION: ignoring file_split_size (always whole file) -> single range -> red. @@ -400,8 +398,9 @@ public void planScanMemoizesPerFileInvariantsAcrossByteSlices() { 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()); + List ranges = provider.planScan(session, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "pt"), Collections.emptyList()) + .build()); 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. @@ -439,8 +438,9 @@ public void planScanComputesPerFileInvariantsOncePerDistinctFile() { 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()); + List ranges = provider.planScan(session, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "pt"), Collections.emptyList()) + .build()); Assertions.assertTrue(ranges.size() >= 4, "two 96MB files at 32MB splits -> >=4 slices, got " + ranges.size()); @@ -471,8 +471,9 @@ public void planScanRangesCarrySizeProportionalWeight() { .commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); ranges.sort((a, b) -> a.getPath().get().compareTo(b.getPath().get())); // selfSplitWeight == the whole-file byte length (small files are not sub-split, no deletes). The two @@ -502,7 +503,8 @@ public void planScanSelfSplitWeightIncludesDeleteFileSizes() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); List ranges = provider.planScan(new FakeScanSession("UTC", Collections.emptyMap()), - new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); // The position delete attaches to f1's scan task (higher sequence number, same partition). Assertions.assertEquals(1, ranges.size()); @@ -527,8 +529,9 @@ public void planScanFileSplitSizeUsesFileSplitSizeAsWeightDenominator() { ConnectorSession session = new FakeScanSession("UTC", Collections.singletonMap("file_split_size", Long.toString(16 * mb))); - List ranges = provider.planScan( - session, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(session, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertFalse(ranges.isEmpty()); for (ConnectorScanRange r : ranges) { @@ -550,15 +553,16 @@ public void planScanPushesPredicateAndPrunesPartition() { // WHERE p = 1 must push to the scan and prune the p=2 file -> only the p=1 data file is enumerated. // This proves the converted predicate reaches scan.filter and is honoured by iceberg planning. // MUTATION: not applying the filter (scan all) -> 2 ranges -> red. - List filtered = provider.planScan( - null, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), - Optional.of(eqInt("p", 1))); + List filtered = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "pt"), Collections.emptyList()) + .filter(Optional.of(eqInt("p", 1))).build()); Assertions.assertEquals(1, filtered.size()); Assertions.assertEquals("s3://b/db/pt/p1.parquet", filtered.get(0).getPath().get()); // Sanity: with no predicate, both partitions' files are enumerated. - List all = provider.planScan( - null, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + List all = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "pt"), Collections.emptyList()) + .build()); Assertions.assertEquals(2, all.size()); } @@ -575,8 +579,9 @@ public void planScanUnpushablePredicateScansAllFiles() { ConnectorExpression unpushable = new ConnectorComparison(ConnectorComparison.Operator.EQ, new ConnectorColumnRef("id", ConnectorType.of("INT")), new ConnectorLiteral(ConnectorType.of("VARCHAR"), "abc")); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.of(unpushable)); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .filter(Optional.of(unpushable)).build()); Assertions.assertEquals(1, ranges.size()); } @@ -616,8 +621,9 @@ public void planScanPopulatesPerFilePartitionAndFormatCarriers() { .commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "pt"), Collections.emptyList()) + .build()); Assertions.assertEquals(2, ranges.size()); // parquet file -> per-file FORMAT_PARQUET + its own partition spec-id/data-json/columns-from-path. @@ -898,9 +904,11 @@ public void planSystemTableScanProjectsRequestedColumnsExcludingReadableMetrics( .commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "data_files", -1L, null, -1L), - Collections.singletonList(new IcebergColumnHandle("file_size_in_bytes", 1)), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "data_files", -1L, null, -1L), + Collections.singletonList(new IcebergColumnHandle("file_size_in_bytes", 1))) + .build()); Assertions.assertEquals(1, ranges.size(), "one data file -> one metadata split"); FileScanTask task = SerializationUtil.deserializeFromBase64( @@ -939,9 +947,11 @@ public void planSystemTableScanProjectionPreservesRequestedColumnOrderForPositio new IcebergColumnHandle("operation", 4), new IcebergColumnHandle("snapshot_id", 2)); - List ranges = provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), - columns, Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + columns) + .build()); Assertions.assertEquals(1, ranges.size(), "one commit -> one $snapshots metadata split"); FileScanTask task = SerializationUtil.deserializeFromBase64( @@ -996,8 +1006,10 @@ private static TFileRangeDesc positionDeleteRangeDesc(List r private static List planPositionDeletes(Table table, List cols) { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); return provider.planScan(new FakeScanSession("UTC", Collections.emptyMap()), - IcebergTableHandle.forSystemTable("db1", "t1", "position_deletes", -1L, null, -1L), - cols, Optional.empty()); + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "position_deletes", -1L, null, -1L), + cols) + .build()); } @Test @@ -1142,13 +1154,16 @@ public void planScanPinnedToOlderSnapshotReadsOnlyThatSnapshotsFiles() { table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 1024, null, null)).commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List latest = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List latest = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(2, latest.size()); - List pinned = provider.planScan( - null, new IcebergTableHandle("db1", "t1").withSnapshot(s1, null, schemaIdS1), - Collections.emptyList(), Optional.empty()); + List pinned = provider.planScan(null, + ConnectorScanRequest.builder( + new IcebergTableHandle("db1", "t1").withSnapshot(s1, null, schemaIdS1), + Collections.emptyList()) + .build()); Assertions.assertEquals(1, pinned.size()); Assertions.assertTrue(pinned.get(0).getPath().get().endsWith("f1.parquet")); } @@ -1166,9 +1181,11 @@ public void planScanPinnedToTagReadsViaUseRefNotSnapshotId() { long s2 = table.currentSnapshot().snapshotId(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List pinned = provider.planScan( - null, new IcebergTableHandle("db1", "t1").withSnapshot(s2, "tag1", schemaIdS1), - Collections.emptyList(), Optional.empty()); + List pinned = provider.planScan(null, + ConnectorScanRequest.builder( + new IcebergTableHandle("db1", "t1").withSnapshot(s2, "tag1", schemaIdS1), + Collections.emptyList()) + .build()); Assertions.assertEquals(1, pinned.size()); Assertions.assertTrue(pinned.get(0).getPath().get().endsWith("f1.parquet")); } @@ -1184,9 +1201,11 @@ public void countPushdownFollowsTheSnapshotPin() { table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 2000, null, null)).commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List pinned = provider.planScan( - null, new IcebergTableHandle("db1", "t1").withSnapshot(s1, null, schemaIdS1), - Collections.emptyList(), Optional.empty(), -1L, Collections.emptyList(), true); + List pinned = provider.planScan(null, + ConnectorScanRequest.builder( + new IcebergTableHandle("db1", "t1").withSnapshot(s1, null, schemaIdS1), + Collections.emptyList()) + .requiredPartitions(Collections.emptyList()).countPushdown(true).build()); Assertions.assertEquals(1, pinned.size()); Assertions.assertEquals(10L, pinned.get(0).getPushDownRowCount()); } @@ -1281,8 +1300,9 @@ public void planScanReadsRealFormatVersionAndEmitsV3RowLineage() { .commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "v3t"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "v3t"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); TIcebergFileDesc fd = populate(ranges.get(0)).getTableFormatParams().getIcebergParams(); @@ -1314,7 +1334,9 @@ public void planScanAccumulatesRewritableDeletesKeyedByRawDataFilePathForV3() { 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()); + provider.planScan(session, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "v3dv"), Collections.emptyList()) + .build()); Map> sets = IcebergStatementScope.rewritableDeleteSupply(session); Assertions.assertFalse(sets.isEmpty(), "a v3 scan with a live DV must accumulate a supply into the scope"); @@ -1343,7 +1365,9 @@ public void planScanDoesNotAccumulateForVersionTwo() { 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()); + provider.planScan(session, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "v2pd"), Collections.emptyList()) + .build()); Assertions.assertTrue(IcebergStatementScope.rewritableDeleteSupply(session).isEmpty(), "a v2 scan must not accumulate any rewritable supply"); @@ -1365,7 +1389,8 @@ public void planScanUnderNoneScopeIsInert() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); List ranges = provider.planScan(new FakeScanSession("UTC", Collections.emptyMap()), - new IcebergTableHandle("db1", "v3ns"), Collections.emptyList(), Optional.empty()); + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "v3ns"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); } @@ -1381,8 +1406,9 @@ public void planScanRejectsUnsupportedFileFormatFailLoud() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); // MUTATION: leaving the else-branch silent (FORMAT_JNI default) -> no throw -> red. - IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, () -> provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty())); + IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, () -> provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build())); Assertions.assertTrue(ex.getMessage().contains("Unsupported format name: avro"), "message should mirror legacy: " + ex.getMessage()); } @@ -1864,8 +1890,9 @@ public void planScanAttachesRealPositionDeleteEndToEnd() { table.newRowDelta().addDeletes(posDelete).commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); TFileRangeDesc rangeDesc = populate(ranges.get(0)); @@ -1895,8 +1922,9 @@ public void planScanNormalizesDataFilePathButKeepsOriginalFilePathRaw() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); // MUTATION: emitting the raw oss:// range path (the pre-fix behavior) -> red. @@ -1916,8 +1944,9 @@ private static List planCount(IcebergScanPlanProvider provid // 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); + return provider.planScan(session, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .requiredPartitions(Collections.emptyList()).countPushdown(countPushdown).build()); } @Test @@ -2094,12 +2123,12 @@ public void planScanManifestCacheEnabledMatchesSdkPathAndConsumesCache() { // Gate OFF (default): the iceberg SDK splitFiles path (T02). List sdk = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) - .planScan(null, handle, Collections.emptyList(), Optional.empty()); + .planScan(null, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); // Gate ON: the manifest-level path that reads manifests through the cache. IcebergManifestCache cache = new IcebergManifestCache(); List manifest = manifestProvider(manifestCacheProps(), table, cache) - .planScan(emptySession(), handle, Collections.emptyList(), Optional.empty()); + .planScan(emptySession(), ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); // WHY: the manifest-level path must enumerate the SAME data files as the SDK path. MUTATION: a mistake // in the ported planning (wrong manifest/metrics/residual handling) drops or duplicates files -> red. @@ -2122,10 +2151,12 @@ public void planScanManifestCachePrunesPartitionLikeSdk() { Optional wherePeq1 = Optional.of(eqInt("p", 1)); List sdk = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) - .planScan(null, handle, Collections.emptyList(), wherePeq1); + .planScan(null, ConnectorScanRequest.builder(handle, Collections.emptyList()).filter(wherePeq1) + .build()); IcebergManifestCache cache = new IcebergManifestCache(); List manifest = manifestProvider(manifestCacheProps(), table, cache) - .planScan(emptySession(), handle, Collections.emptyList(), wherePeq1); + .planScan(emptySession(), ConnectorScanRequest.builder(handle, Collections.emptyList()) + .filter(wherePeq1).build()); // WHY: partition pruning (ManifestEvaluator + residual) must keep only p=1 in BOTH paths. MUTATION: // dropping the residual/metrics prune in the manifest path -> p=2 leaks in -> sizes differ -> red. @@ -2253,7 +2284,9 @@ public void planScanManifestCacheEmptyTableReturnsNoRanges() { Table table = createTable("t1", SCHEMA, PartitionSpec.unpartitioned()); IcebergManifestCache cache = new IcebergManifestCache(); List ranges = manifestProvider(manifestCacheProps(), table, cache) - .planScan(null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + .planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); // An empty table has no snapshot; the manifest path returns no ranges (legacy parity). MUTATION: // NPE-ing on a null snapshot -> red. Assertions.assertTrue(ranges.isEmpty()); @@ -2270,7 +2303,9 @@ public void planScanManifestGateDisabledByTtlZeroUsesSdkPath() { props.put("meta.cache.iceberg.manifest.ttl-second", "0"); // CacheSpec.isCacheEnabled: ttl==0 disables IcebergManifestCache cache = new IcebergManifestCache(); List ranges = manifestProvider(props, table, cache) - .planScan(null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + .planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); // enable=true but ttl-second=0 -> gate off -> SDK path -> the cache stays empty. MUTATION: ignoring the // ttl!=0 sub-condition -> the manifest path runs -> cache populated -> red. Assertions.assertEquals(1, ranges.size()); @@ -2300,10 +2335,10 @@ public void planScanManifestCacheAssociatesDeletesLikeSdk() { IcebergTableHandle handle = new IcebergTableHandle("db1", "t1"); List sdk = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)) - .planScan(null, handle, Collections.emptyList(), Optional.empty()); + .planScan(null, ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); IcebergManifestCache cache = new IcebergManifestCache(); List manifest = manifestProvider(manifestCacheProps(), table, cache) - .planScan(emptySession(), handle, Collections.emptyList(), Optional.empty()); + .planScan(emptySession(), ConnectorScanRequest.builder(handle, Collections.emptyList()).build()); Assertions.assertEquals(1, sdk.size()); Assertions.assertEquals(1, manifest.size()); @@ -2521,8 +2556,9 @@ public void planScanThreadsVendedTokenIntoDataAndDeletePathNormalize() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); // WHY: both the data-file path and the delete-file path must route through the 2-arg @@ -2554,8 +2590,9 @@ public void planScanDerivesUriNormalizerOncePerScanNotPerFile() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(3, ranges.size()); Assertions.assertEquals(1, context.newNormalizerCount); @@ -2599,8 +2636,9 @@ public void planScanDefaultSplitHeuristicTilesAndMaxFileSplitNumCapCollapses() { // (12.8GB) so no escalation -> 32MB initial target; the 100000-file cap is far below 32MB -> the file // tiles into >1 contiguous ranges. MUTATION: bypassing determineTargetFileSplitSize (whole file) -> 1 // range -> red. (This is the default branch, distinct from the override branch the existing test drives.) - List def = provider.planScan( - null, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List def = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertTrue(def.size() > 1, "default heuristic must tile the 96MB file, got " + def.size()); def.sort((a, b) -> Long.compare(a.getStart(), b.getStart())); long expectedStart = 0; @@ -2616,8 +2654,9 @@ public void planScanDefaultSplitHeuristicTilesAndMaxFileSplitNumCapCollapses() { // target to the whole file -> exactly ONE range. This is the ONLY test driving the cap escalation // (Math.max(result, minSplitSizeForMaxNum)). MUTATION: dropping the cap -> target stays 32MB -> >1 -> red. ConnectorSession capOne = new FakeScanSession("UTC", Collections.singletonMap("max_file_split_num", "1")); - List capped = provider.planScan( - capOne, new IcebergTableHandle("db1", "t1"), Collections.emptyList(), Optional.empty()); + List capped = provider.planScan(capOne, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "t1"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, capped.size(), "max_file_split_num=1 must collapse to one whole-file range"); Assertions.assertEquals(0L, capped.get(0).getStart()); Assertions.assertEquals(96 * mb, capped.get(0).getLength()); @@ -2639,8 +2678,9 @@ public void planScanPartitionBearingFileWithNoIdentityValuesEmitsNoColumnsFromPa .commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "ev"), Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "ev"), Collections.emptyList()) + .build()); Assertions.assertEquals(1, ranges.size()); ConnectorScanRange range = ranges.get(0); Assertions.assertTrue(range.isPartitionBearing(), "a bucket-partitioned file is partition-bearing"); @@ -2724,8 +2764,9 @@ public void planScanCombinesPartitionPruneDeleteAndPathNormalizeOnOneRange() { IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table), context); - List ranges = provider.planScan( - null, new IcebergTableHandle("db1", "pt"), Collections.emptyList(), Optional.of(eqInt("p", 1))); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder(new IcebergTableHandle("db1", "pt"), Collections.emptyList()) + .filter(Optional.of(eqInt("p", 1))).build()); // (a) predicate pruned p=2 -> exactly the p=1 data file survives, scheme-normalized; original raw. Assertions.assertEquals(1, ranges.size()); @@ -2856,9 +2897,11 @@ public void planScanForSystemTableSerializesEachFileScanTaskAsJniSplit() { table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), - Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + Collections.emptyList()) + .build()); Assertions.assertFalse(ranges.isEmpty(), "the $snapshots metadata table must plan at least one split"); for (ConnectorScanRange range : ranges) { @@ -2885,9 +2928,11 @@ public void planScanForSystemTableSplitDeserializesThroughTheBeJniReaderPath() t table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), - Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + Collections.emptyList()) + .build()); long snapshotRows = 0; for (ConnectorScanRange range : ranges) { @@ -2924,12 +2969,16 @@ public void planScanForSystemTableHonorsTheSnapshotPin() throws Exception { table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f2.parquet", 1024, null, null)).commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - long latestRows = countSerializedSplitRows(provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "files", -1L, null, -1L), - Collections.emptyList(), Optional.empty())); - long pinnedRows = countSerializedSplitRows(provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "files", s1, null, -1L), - Collections.emptyList(), Optional.empty())); + long latestRows = countSerializedSplitRows(provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "files", -1L, null, -1L), + Collections.emptyList()) + .build())); + long pinnedRows = countSerializedSplitRows(provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "files", s1, null, -1L), + Collections.emptyList()) + .build())); Assertions.assertEquals(2L, latestRows, "latest $files should list both data files"); Assertions.assertEquals(1L, pinnedRows, "pinned-to-S1 $files should list only S1's file"); @@ -2947,8 +2996,11 @@ public void planScanForSystemTableLoadsMetadataInsideTheAuthScope() { RecordingConnectorContext context = new RecordingConnectorContext(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), ops, context); - provider.planScan(null, IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), - Collections.emptyList(), Optional.empty()); + provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + Collections.emptyList()) + .build()); Assertions.assertEquals(1, context.authCount); Assertions.assertEquals("db1", ops.lastLoadDb); @@ -2978,12 +3030,16 @@ public void planScanForSystemTableCarriesPredicateAsResidualForBe() throws Excep new ConnectorColumnRef("record_count", ConnectorType.of("BIGINT")), new ConnectorLiteral(ConnectorType.of("BIGINT"), 10L)); - String unfilteredResidual = firstSysSplitResidual(provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "files", -1L, null, -1L), - Collections.emptyList(), Optional.empty())); - String filteredResidual = firstSysSplitResidual(provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "files", -1L, null, -1L), - Collections.emptyList(), Optional.of(recordCountEq10))); + String unfilteredResidual = firstSysSplitResidual(provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "files", -1L, null, -1L), + Collections.emptyList()) + .build())); + String filteredResidual = firstSysSplitResidual(provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "files", -1L, null, -1L), + Collections.emptyList()) + .filter(Optional.of(recordCountEq10)).build())); // No predicate -> the metadata scan carries no residual (alwaysTrue); a pushable predicate -> the // residual references record_count, proving the converted filter reached scan.filter. @@ -3012,9 +3068,11 @@ public void planScanForSystemTableSetsDummyPathOnEverySplit() { table.newAppend().appendFile(dataFile(table.spec(), "s3://b/db/t1/f1.parquet", 1024, null, null)).commit(); IcebergScanPlanProvider provider = new IcebergScanPlanProvider(Collections.emptyMap(), opsReturning(table)); - List ranges = provider.planScan( - null, IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), - Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(null, + ConnectorScanRequest.builder( + IcebergTableHandle.forSystemTable("db1", "t1", "snapshots", -1L, null, -1L), + Collections.emptyList()) + .build()); Assertions.assertFalse(ranges.isEmpty(), "the $snapshots metadata table must plan at least one split"); for (ConnectorScanRange range : ranges) { diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporterTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporterTest.java index 250e42f396cead..076042b6722086 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporterTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanProfileReporterTest.java @@ -100,7 +100,7 @@ public void formattersMatchLegacy() { } @Test - public void groupNameMatchesFeCoreConstant() { + public void groupNameIsTheUserVisibleProfileSection() { Assertions.assertEquals("Iceberg Scan Metrics", IcebergScanProfileReporter.GROUP_NAME); } } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanRangeTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanRangeTest.java index 2153fa327a115b..7f451815186b0f 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanRangeTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanRangeTest.java @@ -17,7 +17,6 @@ package org.apache.doris.connector.iceberg; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileFormatType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TIcebergDeleteFileDesc; @@ -53,9 +52,6 @@ public void builderProducesFileScanRangeWithFileFields() { .fileFormat("parquet") .build(); - // WHY: iceberg is a file-based connector, so the engine must build a TFileScanRange off this range. - // MUTATION: returning JDBC_SCAN / CUSTOM -> wrong thrift scan-range variant -> red. - Assertions.assertEquals(ConnectorScanRangeType.FILE_SCAN, range.getRangeType()); Assertions.assertEquals(Optional.of("s3://bucket/db/t/data/f.parquet"), range.getPath()); Assertions.assertEquals(128L, range.getStart()); Assertions.assertEquals(4096L, range.getLength()); diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderTest.java index 072259bfcd4fdb..a46474334d8e98 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergSchemaBuilderTest.java @@ -212,7 +212,7 @@ private static Schema partSchema() { private static ConnectorPartitionSpec spec(ConnectorPartitionField... fields) { return new ConnectorPartitionSpec( - ConnectorPartitionSpec.Style.TRANSFORM, Arrays.asList(fields), Collections.emptyList()); + ConnectorPartitionSpec.Style.TRANSFORM, Arrays.asList(fields)); } @Test @@ -235,8 +235,7 @@ public void testIdentityPartition() { PartitionSpec result = IcebergSchemaBuilder.buildPartitionSpec( new ConnectorPartitionSpec(ConnectorPartitionSpec.Style.IDENTITY, Collections.singletonList(new ConnectorPartitionField("name", "identity", - Collections.emptyList())), - Collections.emptyList()), + Collections.emptyList()))), schema); Assertions.assertEquals(1, result.fields().size()); Assertions.assertEquals("identity", result.fields().get(0).transform().toString()); 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 27b1b07cc9f2ef..991586a34434ff 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 @@ -1151,7 +1151,7 @@ public boolean isOverwrite() { } @Override - public Map getWriteContext() { + public Map getStaticPartitionSpec() { return writeContext; } 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 7876783a04d6c2..37b39bf5b5e528 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 @@ -18,9 +18,11 @@ package org.apache.doris.connector.iceberg; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; -import org.apache.doris.connector.spi.ConnectorMetaInvalidator; +import org.apache.doris.connector.spi.ConnectorStorageContext; +import org.apache.doris.filesystem.FileSystem; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.thrift.TFileType; @@ -42,7 +44,15 @@ * {@link #executeAuthenticated} throws WITHOUT invoking the task, which proves the seam call sits INSIDE * the authenticator. */ -final class RecordingConnectorContext implements ConnectorContext { +final class RecordingConnectorContext implements ConnectorContext, ConnectorStorageContext { + + // Storage services moved onto ConnectorStorageContext; this double implements both halves and hands + // itself back, so its overrides below are the ones the connector reaches. Forgetting this getter would + // silently give the connector NOOP and make those overrides dead code. + @Override + public ConnectorStorageContext getStorageContext() { + return this; + } int authCount; boolean failAuth; @@ -76,19 +86,6 @@ final class RecordingConnectorContext implements ConnectorContext { * so a FILE_BROKER write fails loud ("No alive broker.") unless a test populates it. */ List brokerAddresses = Collections.emptyList(); - /** "db.table" keys the connector invalidated via {@link #getMetaInvalidator()} (P6.4 procedure dispatch). */ - final List invalidatedTables = new ArrayList<>(); - - @Override - public ConnectorMetaInvalidator getMetaInvalidator() { - return new ConnectorMetaInvalidator() { - @Override - public void invalidateTable(String dbName, String tableName) { - invalidatedTables.add(dbName + "." + tableName); - } - }; - } - @Override public String getCatalogName() { return "test"; @@ -184,4 +181,16 @@ public void cleanupEmptyManagedLocation(String location, List tableChild cleanedLocations.add(location); cleanedChildDirs.add(tableChildDirs); } + + // A distinguishable, non-null engine filesystem. The SPI default for getFileSystem is null, so a + // decorator that forgets to forward it hands the connector null instead of this instance. + final FileSystem engineFileSystem = (FileSystem) java.lang.reflect.Proxy.newProxyInstance( + RecordingConnectorContext.class.getClassLoader(), new Class[] {FileSystem.class}, + (proxy, method, args) -> null); + + @Override + public FileSystem getFileSystem(ConnectorSession session) { + return engineFileSystem; + } + } diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContextTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContextTest.java index 7699f6cb188c3a..dc9c86d6859310 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContextTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContextTest.java @@ -130,6 +130,22 @@ public void delegatesNonAuthMethods() { "createSiblingConnector properties must reach the delegate unchanged"); } + @Test + public void delegatesEngineFileSystem() { + RecordingConnectorContext delegate = new RecordingConnectorContext(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, isolatedLoader(), () -> null); + + // This was the actual gap: the decorator had no getFileSystem pass-through, so the call fell to + // the SPI default and the connector got null instead of the engine's per-catalog filesystem. It + // compiles either way, which is precisely why it went unnoticed - and why the first connector to + // reach for the engine filesystem would have debugged an NPE, or (if it copied hive's null check) a + // message blaming the catalog's storage properties, which are fine. Storage now reaches the + // connector through the single getStorageContext() forward, so this asserts that ONE forward: lose + // it and every storage service degrades at once, exactly as getFileSystem alone used to. + Assertions.assertSame(delegate.engineFileSystem, ctx.getStorageContext().getFileSystem(null), + "getFileSystem must reach the wrapped engine context, not the SPI default (null)"); + } + @Test public void kerberosRunsTaskInPluginDoAsAndBypassesDelegateAuth() throws Exception { // Single-owner auth (Option A): a Kerberos catalog runs the op under the PLUGIN authenticator's doAs and diff --git a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesActionTest.java b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesActionTest.java index fc09263a143b22..a21d1bc85f9c88 100644 --- a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesActionTest.java +++ b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/action/IcebergRewriteDataFilesActionTest.java @@ -17,6 +17,7 @@ package org.apache.doris.connector.iceberg.action; +import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.iceberg.rewrite.RewriteDataFilePlanner; @@ -26,6 +27,7 @@ import org.junit.jupiter.api.Test; import java.util.Collections; +import java.util.stream.Collectors; /** * Pins the argument spec + planning-parameter build of the connector {@link IcebergRewriteDataFilesAction} @@ -111,4 +113,22 @@ public void executeActionThrowsBecauseDistributed() { () -> a.execute(null, null)); Assertions.assertTrue(e.getMessage().contains("distributed procedure"), e.getMessage()); } + + @Test + public void declaresItsResultSchemaLikeItsSingleCallSiblings() { + // The columns of this procedure's result used to live in the engine's rewrite driver. They belong to + // the procedure, so the action declares them the way the eight single-call actions do — even though + // BaseIcebergAction's own execute() path is unreachable here. The schema is a STATIC constant on + // purpose: BaseIcebergAction captures getResultSchema() from its constructor, so an instance field + // would still be null there and the action would report no columns at all. + IcebergRewriteDataFilesAction a = action(Collections.emptyMap(), Collections.emptyList()); + Assertions.assertEquals( + ImmutableList.of("rewritten_data_files_count", "added_data_files_count", + "rewritten_bytes_count", "removed_delete_files_count"), + a.getResultSchema().stream().map(ConnectorColumn::getName).collect(Collectors.toList())); + Assertions.assertEquals( + ImmutableList.of("INT", "INT", "INT", "BIGINT"), + a.getResultSchema().stream().map(c -> c.getType().getTypeName()) + .collect(Collectors.toList())); + } } diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java index 4897367e57fa06..bebf93ac500602 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcConnectorMetadata.java @@ -19,6 +19,7 @@ import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPassthroughSqlOps; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.ConnectorStatementScopes; import org.apache.doris.connector.api.ConnectorTableSchema; @@ -46,7 +47,7 @@ * {@link ConnectorMetadata} implementation for JDBC sources. * Delegates metadata discovery to {@link JdbcConnectorClient}. */ -public class JdbcConnectorMetadata implements ConnectorMetadata { +public class JdbcConnectorMetadata implements ConnectorMetadata, ConnectorPassthroughSqlOps { private static final Logger LOG = LogManager.getLogger(JdbcConnectorMetadata.class); @@ -182,11 +183,6 @@ public Map getColumnHandles( return handles; } - @Override - public Map getProperties() { - return properties; - } - @Override public org.apache.doris.thrift.TTableDescriptor buildTableDescriptor( ConnectorSession session, @@ -255,11 +251,6 @@ public String fromRemoteColumnName(ConnectorSession session, remoteDatabaseName, remoteTableName, remoteColumnName); } - @Override - public List getPrimaryKeys(ConnectorSession session, String dbName, String tableName) { - return client.getPrimaryKeys(dbName, tableName); - } - @Override public String getTableComment(ConnectorSession session, String dbName, String tableName) { return client.getTableComment(dbName, tableName); diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcDorisConnector.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcDorisConnector.java index 615e8184b13963..885f19a7a046c0 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcDorisConnector.java +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcDorisConnector.java @@ -102,8 +102,9 @@ public Set getCapabilities() { // SUPPORTS_METADATA_PRELOAD: preserves the legacy engine-name "jdbc" gate of // PluginDrivenExternalTable.supportsExternalMetadataPreload (F11) now that it is capability-driven, so // jdbc tables keep async metadata pre-load. + // Passthrough SQL is NOT declared here: JdbcConnectorMetadata implements + // ConnectorPassthroughSqlOps, and implementing that interface IS the declaration. return EnumSet.of( - ConnectorCapability.SUPPORTS_PASSTHROUGH_QUERY, ConnectorCapability.SUPPORTS_METADATA_PRELOAD ); } @@ -301,7 +302,7 @@ private JdbcConnectorClient createClient() { poolMinSize, poolMaxSize, poolMaxWaitTime, poolMaxLifeTime, onlySpecifiedDatabase, properties, enableMappingVarbinary, enableMappingTimestampTz, - context::sanitizeJdbcUrl); + context::sanitizeOutboundUrl); } @Override diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanPlanProvider.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanPlanProvider.java index 47fa2f247b44ad..cbfd443b4f95af 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanPlanProvider.java @@ -24,7 +24,8 @@ import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -58,26 +59,11 @@ public JdbcScanPlanProvider(JdbcDbType dbType, Map catalogProper } @Override - public ConnectorScanRangeType getScanRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - - @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - return planScan(session, handle, columns, filter, -1); - } - - @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit) { + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + ConnectorTableHandle handle = request.getTableHandle(); + List columns = request.getColumns(); + Optional filter = request.getFilter(); + long limit = request.getLimit(); String querySql; if (handle instanceof PassthroughQueryTableHandle) { // Query passthrough from TVF — use the raw SQL directly @@ -148,11 +134,6 @@ public List planScan( return Collections.singletonList(scanRange); } - @Override - public long estimateScanRangeCount(ConnectorSession session, ConnectorTableHandle handle) { - return 1; - } - private String getProperty(String key, String defaultValue) { return catalogProperties.getOrDefault(key, defaultValue); } @@ -182,7 +163,7 @@ public Map getScanNodeProperties( columns, filter, -1); } Map props = new HashMap<>(); - props.put("query", querySql); + props.put(ScanNodePropertyKeys.REMOTE_QUERY, querySql); return props; } } diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanRange.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanRange.java index c9f31caae176f1..96b1fb1e1a0dad 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanRange.java +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcScanRange.java @@ -18,7 +18,6 @@ package org.apache.doris.connector.jdbc; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import java.util.Collections; import java.util.LinkedHashMap; @@ -44,11 +43,6 @@ private JdbcScanRange(Map properties) { this.properties = Collections.unmodifiableMap(new LinkedHashMap<>(properties)); } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Optional getPath() { return Optional.of("jdbc://virtual"); diff --git a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClient.java b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClient.java index b75a0c1828c330..f2086332080559 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClient.java +++ b/fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/client/JdbcConnectorClient.java @@ -91,7 +91,8 @@ public abstract class JdbcConnectorClient implements Closeable { /** * Factory method to create the correct client subclass for the given DB type. * - * @param urlSanitizer engine-level JDBC URL sanitizer (e.g., SSRF protection). + * @param urlSanitizer engine-level outbound URL sanitizer (e.g., SSRF protection). Applied here because + * this is where the connector itself opens the connection. * Applied before passing the URL to HikariCP. Pass * {@link UnaryOperator#identity()} if no sanitization is needed. */ diff --git a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcDorisConnectorTest.java b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcDorisConnectorTest.java index cb7c49076f52cd..7ebfcd9c4bccd5 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcDorisConnectorTest.java +++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcDorisConnectorTest.java @@ -19,11 +19,13 @@ import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorContractValidator; +import org.apache.doris.connector.api.ConnectorPassthroughSqlOps; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTransaction; import org.apache.doris.connector.api.handle.NoOpConnectorTransaction; import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import org.apache.doris.connector.spi.ConnectorContext; import org.junit.jupiter.api.Assertions; @@ -102,6 +104,17 @@ void testDeclaresMetadataPreloadCapability() { + "capability conversion"); } + @Test + void testDeclaresPassthroughSqlByImplementingTheOptionalInterface() { + // jdbc is the connector behind query() and CALL EXECUTE_STMT, and the engine admits it by type-checking + // the metadata against ConnectorPassthroughSqlOps -- implementing that interface IS the declaration + // (it replaced a SUPPORTS_PASSTHROUGH_QUERY flag that could disagree with the implementation). + // MUTATION: dropping the interface from JdbcConnectorMetadata makes both entry points refuse every + // jdbc catalog with "not supported" -> red here. + Assertions.assertTrue(ConnectorPassthroughSqlOps.class.isAssignableFrom(JdbcConnectorMetadata.class), + "jdbc must implement ConnectorPassthroughSqlOps or query()/EXECUTE_STMT stop admitting it"); + } + @Test void testDoubleCloseNoException() throws IOException { JdbcDorisConnector connector = new JdbcDorisConnector(minimalProps(), testContext()); @@ -179,9 +192,11 @@ void testJdbcConnectorSupportsInsertOnly() { props.put(JdbcConnectorProperties.JDBC_URL, "jdbc:postgresql://localhost:5432/test"); props.put(JdbcConnectorProperties.DRIVER_CLASS, "java.lang.Object"); JdbcDorisConnector connector = new JdbcDorisConnector(props, testContext()); - Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT), connector.supportedWriteOperations(), + ConnectorWritePlanProvider writeProvider = connector.getWritePlanProvider(); + Assertions.assertNotNull(writeProvider, "JDBC connector must expose a write plan provider"); + Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT), writeProvider.supportedOperations(), "JDBC connector should declare INSERT as its only supported write operation"); - Assertions.assertFalse(connector.supportsWriteBranch(), + Assertions.assertFalse(writeProvider.supportsWriteBranch(), "JDBC connector should not support writing into a named table branch"); // Task 6 P2: the structural contract validator must pass for a real connector (positive control). ConnectorContractValidator.validate(connector, "jdbc"); diff --git a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcScanRangeAndPropertiesTest.java b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcScanRangeAndPropertiesTest.java index fde00ac5ebd6da..015a4330716155 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcScanRangeAndPropertiesTest.java +++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcScanRangeAndPropertiesTest.java @@ -17,7 +17,8 @@ package org.apache.doris.connector.jdbc; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; +import org.apache.doris.thrift.TFileRangeDesc; +import org.apache.doris.thrift.TTableFormatFileDesc; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -73,12 +74,6 @@ void testBuildFullScanRange() { Assertions.assertEquals("true", props.get("connection_pool_keep_alive")); } - @Test - void testScanRangeType() { - JdbcScanRange range = new JdbcScanRange.Builder().build(); - Assertions.assertEquals(ConnectorScanRangeType.FILE_SCAN, range.getRangeType()); - } - @Test void testScanRangePath() { JdbcScanRange range = new JdbcScanRange.Builder().build(); @@ -92,6 +87,42 @@ void testScanRangeTableFormatType() { Assertions.assertEquals("jdbc", range.getTableFormatType()); } + /** + * Pins what the DEFAULT {@code ConnectorScanRange.populateRangeParams} sends to BE. + * + *

    WHY this test lives in the jdbc module: {@code JdbcScanRange} is the only shipped range that does not + * override {@code populateRangeParams} (the other seven build their own typed thrift descriptor), so this + * is the only production path through the default implementation — and until this test it had no coverage + * at all. The {@code jdbc_params} map it fills IS the input of BE's jdbc reader + * ({@code file_scanner.cpp} / {@code jdbc_reader.cpp} read it, and the JNI scanner picks keys out of it by + * name), so dropping a key from that map is a wire-visible change, not a cleanup.

    + */ + @Test + void testPopulateRangeParamsCarriesBeConsumedKeysAndNoDeadRangeTypeKey() { + JdbcScanRange range = new JdbcScanRange.Builder() + .querySql("SELECT * FROM t") + .jdbcUrl("jdbc:mysql://host:3306/db") + .jdbcUser("root") + .build(); + + TTableFormatFileDesc formatDesc = new TTableFormatFileDesc(); + range.populateRangeParams(formatDesc, new TFileRangeDesc()); + Map params = formatDesc.getJdbcParams(); + + // The keys BE really reads must survive untouched. + Assertions.assertEquals("SELECT * FROM t", params.get("query_sql")); + Assertions.assertEquals("jdbc:mysql://host:3306/db", params.get("jdbc_url")); + Assertions.assertEquals("root", params.get("jdbc_user")); + Assertions.assertEquals("jni", params.get("connector_file_format"), + "jdbc is read through the JNI scanner framework"); + + // MUTATION: re-adding props.put("connector_scan_range_type", ...) in the default + // populateRangeParams turns this red. Neither BE (zero hits across be/) nor JdbcJniScanner reads that + // key, and every connector returned the same value for it, so it was pure freight. + Assertions.assertFalse(params.containsKey("connector_scan_range_type"), + "the scan-range-type key is dead freight: nothing on the BE side reads it"); + } + @Test void testScanRangePropertiesAreUnmodifiable() { JdbcScanRange range = new JdbcScanRange.Builder() diff --git a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java index 46f6ff2c013a92..640bebfc2b5340 100644 --- a/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java +++ b/fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcWritePlanProviderTest.java @@ -109,7 +109,7 @@ public boolean isOverwrite() { } @Override - public Map getWriteContext() { + public Map getStaticPartitionSpec() { return Collections.emptyMap(); } }; diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java index f07ed7ea2430ef..46317db26bde94 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadata.java @@ -296,24 +296,6 @@ public List listPartitions(ConnectorSession session, return result; } - @Override - public List> listPartitionValues(ConnectorSession session, - ConnectorTableHandle handle, List partitionColumns) { - MaxComputeTableHandle mcHandle = (MaxComputeTableHandle) handle; - List partitions = partitionCache.getPartitions( - mcHandle.getDbName(), mcHandle.getTableName()); - List> result = new ArrayList<>(partitions.size()); - for (Partition partition : partitions) { - PartitionSpec spec = partition.getPartitionSpec(); - List values = new ArrayList<>(partitionColumns.size()); - for (String column : partitionColumns) { - values.add(spec.get(column)); - } - result.add(values); - } - return result; - } - // ==================== Write / Transaction (P4-T03 / P4-T04) ==================== /** @@ -452,11 +434,6 @@ public void dropTable(ConnectorSession session, // ==================== DDL: Create/Drop Database ==================== - @Override - public boolean supportsCreateDatabase() { - return true; - } - @Override public void createDatabase(ConnectorSession session, String dbName, Map properties) { diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java index 07affd6a03427d..b24d9a147cf882 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProvider.java @@ -22,8 +22,10 @@ import org.apache.doris.connector.spi.ConnectorProvider; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; /** * SPI entry point for the MaxCompute (ODPS) connector plugin. @@ -47,6 +49,26 @@ public Connector create(Map properties, return new MaxComputeDorisConnector(properties, context); } + /** + * {@code CREATE TABLE ... ENGINE=maxcompute} keeps working; omitting the clause is equivalent. The engine + * keyword is legacy syntax the connector owns, not the catalog type and not the displayed engine name. + */ + @Override + public Set acceptedCreateTableEngineNames() { + return Collections.singleton("maxcompute"); + } + + /** + * Spelled without the underscore that {@link #getType()} carries: the catalog type is the internal token a + * user writes in {@code CREATE CATALOG}, whereas this is the product name shown in the {@code ENGINE} + * column and after {@code ENGINE=}. It coincides with the accepted CREATE TABLE engine name above, but the + * two are answered separately — nothing keeps them equal, and for other connectors they differ. + */ + @Override + public String displayEngineName() { + return "maxcompute"; + } + /** * Validates catalog properties at CREATE CATALOG time, mirroring the fail-fast * checks of the legacy {@code MaxComputeExternalCatalog.checkProperties}: required diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCache.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCache.java index 759bbeffe4ee83..2cc4afda30d5e7 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCache.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputePartitionCache.java @@ -38,8 +38,8 @@ *

    Why this exists. Legacy fe-core kept partition listings in the engine-side external meta cache; once * a MaxCompute catalog becomes plugin-driven that cache stops routing to it, so without this connector-owned * cache every {@code SHOW PARTITIONS} / partition-pruning / partition-values call would re-list every partition - * from ODPS. The three metadata consumers ({@code listPartitions}, {@code listPartitionNames}, - * {@code listPartitionValues}) share ONE instance, held as a {@code final} field on the per-catalog + * from ODPS. Both metadata consumers ({@code listPartitions}, {@code listPartitionNames}) share ONE + * instance, held as a {@code final} field on the per-catalog * {@link MaxComputeDorisConnector} (the only object that outlives a single query; the metadata is rebuilt per * call), so a partition read warms the others. * diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanPlanProvider.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanPlanProvider.java index 6cf9fb69a5bad7..59ee8961ea7a8d 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanPlanProvider.java @@ -28,6 +28,7 @@ import org.apache.doris.connector.api.pushdown.ConnectorLiteral; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import com.aliyun.odps.Column; import com.aliyun.odps.OdpsType; @@ -161,25 +162,13 @@ private void initFromProperties() { } @Override - public List planScan(ConnectorSession session, - ConnectorTableHandle handle, List columns, - Optional filter) { - return planScan(session, handle, columns, filter, -1); - } - - @Override - public List planScan(ConnectorSession session, - ConnectorTableHandle handle, List columns, - Optional filter, long limit) { - return planScan(session, handle, columns, filter, limit, null); - } - - @Override - public List planScan(ConnectorSession session, - ConnectorTableHandle handle, List columns, - Optional filter, long limit, List requiredPartitions) { + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + List columns = request.getColumns(); + Optional filter = request.getFilter(); + long limit = request.getLimit(); + List requiredPartitions = request.getRequiredPartitions(); ensureInitialized(); - MaxComputeTableHandle mcHandle = (MaxComputeTableHandle) handle; + MaxComputeTableHandle mcHandle = (MaxComputeTableHandle) request.getTableHandle(); Table odpsTable = mcHandle.getOdpsTable(); // Reject external tables / logical views before any read planning (mirrors legacy diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanRange.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanRange.java index ecdd11cc558e48..51b27eb22957e8 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanRange.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeScanRange.java @@ -18,7 +18,6 @@ package org.apache.doris.connector.maxcompute; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TMaxComputeFileDesc; import org.apache.doris.thrift.TTableFormatFileDesc; @@ -61,11 +60,6 @@ private MaxComputeScanRange(Builder builder) { this.properties = Collections.unmodifiableMap(builder.properties); } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public String getTableFormatType() { return "max_compute"; diff --git a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java index 438897b3046930..92e43710d90340 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/main/java/org/apache/doris/connector/maxcompute/MaxComputeWritePlanProvider.java @@ -96,7 +96,7 @@ public ConnectorSinkPlan planWrite(ConnectorSession session, ConnectorWriteHandl boolean isOverwrite = handle.isOverwrite(); // Static partition spec carried as a col -> val map in the write context (D-5). - Map staticPartitionSpec = handle.getWriteContext(); + Map staticPartitionSpec = handle.getStaticPartitionSpec(); boolean isStaticPartition = staticPartitionSpec != null && !staticPartitionSpec.isEmpty(); // Partition column names, taken from the ODPS table (DV-012: legacy reads diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorContractTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorContractTest.java index 3995e793a44a4b..06fec2c6cd9ace 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorContractTest.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorContractTest.java @@ -19,6 +19,7 @@ import org.apache.doris.connector.api.ConnectorContractValidator; import org.apache.doris.connector.api.handle.WriteOperation; +import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -54,14 +55,16 @@ private static Map validProps() { public void declaredWriteCapabilitiesMatchAndPassContractValidator() { MaxComputeDorisConnector connector = new MaxComputeDorisConnector(validProps(), null); + ConnectorWritePlanProvider writeProvider = connector.getWritePlanProvider(); + Assertions.assertNotNull(writeProvider, "MaxCompute connector must expose a write plan provider"); Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE), - connector.supportedWriteOperations()); - Assertions.assertFalse(connector.supportsWriteBranch(), + writeProvider.supportedOperations()); + Assertions.assertFalse(writeProvider.supportsWriteBranch(), "MaxCompute does not support writing into a named table branch"); - Assertions.assertTrue(connector.requiresParallelWrite()); - Assertions.assertTrue(connector.requiresFullSchemaWriteOrder()); - Assertions.assertTrue(connector.requiresPartitionLocalSort()); - Assertions.assertFalse(connector.requiresMaterializeStaticPartitionValues()); + Assertions.assertTrue(writeProvider.requiresParallelWrite()); + Assertions.assertTrue(writeProvider.requiresFullSchemaWriteOrder()); + Assertions.assertTrue(writeProvider.requiresPartitionLocalSort()); + Assertions.assertFalse(writeProvider.requiresMaterializeStaticPartitionValues()); ConnectorContractValidator.validate(connector, "max_compute"); } diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataCapabilityTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataCapabilityTest.java index b92c651635ea6c..0b216c9b9f5f1f 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataCapabilityTest.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorMetadataCapabilityTest.java @@ -23,34 +23,13 @@ import java.util.Collections; /** - * P2-6 FIX-CREATE-DB-PRECHECK (clean-room re-review DG-4 / F26, F23) — pins the - * MaxCompute schema-op capability declaration the FE CREATE DATABASE precheck depends on. + * Pins the MaxCompute metadata capability declarations that change what the engine does. * - *

    WHY this matters: the fix for DG-4 gates the FE - * {@code CREATE DATABASE IF NOT EXISTS} remote-existence precheck on - * {@code ConnectorSchemaOps.supportsCreateDatabase()} (default false) so that jdbc/es/trino — - * which cannot create databases — keep their existing "not supported" behavior. MaxCompute CAN - * create databases and MUST declare {@code true}, otherwise the precheck is skipped for it and - * the very regression DG-4 describes (CREATE DATABASE IF NOT EXISTS on a remotely-existing db - * surfacing ODPS "already exists") returns. The fe-core routing tests use a mocked connector, so - * this is the only test that pins the real MaxCompute override. MUTATION: flipping the override - * to {@code return false} makes this red. The capability getter touches no instance field, so a - * {@code null} odps/helper keeps the test offline (same pattern as - * {@link MaxComputeBuildTableDescriptorTest}).

    + *

    A capability getter touches no instance field, so a {@code null} odps/helper keeps these tests + * offline (same pattern as {@link MaxComputeBuildTableDescriptorTest}).

    */ public class MaxComputeConnectorMetadataCapabilityTest { - @Test - public void maxComputeDeclaresSupportsCreateDatabase() { - MaxComputeConnectorMetadata metadata = new MaxComputeConnectorMetadata( - null, null, "proj", "ep", "quota", Collections.emptyMap(), - null); // null: partition cache unused by this test - - Assertions.assertTrue(metadata.supportsCreateDatabase(), - "MaxCompute must declare supportsCreateDatabase()=true so the FE " - + "CREATE DATABASE IF NOT EXISTS remote precheck applies to it (DG-4)"); - } - /** * F9 FIX-CAST-PUSHDOWN — pins that MaxCompute disables CAST-predicate pushdown. * diff --git a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderTest.java b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderTest.java index 479e3c0bfc24f8..ba211d29ce7592 100644 --- a/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderTest.java +++ b/fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorProviderTest.java @@ -54,6 +54,15 @@ public void testValidPropertiesPass() { Assertions.assertDoesNotThrow(() -> provider.validateProperties(validProps())); } + @Test + public void testDisplayEngineNameDropsTheUnderscoreOfTheCatalogType() { + // This connector is the reason the displayed engine name is a separate declaration at all: the catalog + // type a user writes carries an underscore, the product name does not, and the engine has no way to + // know that. It shows up in the ENGINE column and after ENGINE= in SHOW CREATE TABLE. + Assertions.assertEquals("max_compute", provider.getType()); + Assertions.assertEquals("maxcompute", provider.displayEngineName()); + } + // --- 1. required PROJECT / ENDPOINT --- @Test diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java index b2e8acc2d2ccdb..be34d6ccf40ad2 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonCatalogFactory.java @@ -45,7 +45,7 @@ * storage {@code Configuration} from the pre-computed canonical object-store config) and * {@link #assembleHiveConf} (layers the shared-parser HiveConf overrides over an optional hive-site.xml * base for the hms flavor). The {@code storageHadoopConfig} arg is assembled by - * {@code PaimonConnector} from {@code ConnectorContext.getStorageProperties()} (fe-filesystem's + * {@code PaimonConnector} from {@code ConnectorStorageContext.getStorageProperties()} (fe-filesystem's * {@code toHadoopProperties().toHadoopConfigurationMap()}), so the helpers stay pure (Maps in, conf out) * and unit-testable offline; only the {@code CatalogFactory.createCatalog} call in * {@code PaimonConnector} needs a live metastore. @@ -228,7 +228,7 @@ private static void appendJdbcOptions(Map props, Options options *
  • {@code storageHadoopConfig} carries the canonical object-store translation * ({@code s3.*}/{@code oss.*}/{@code cos.*}/{@code obs.*}/{@code AWS_*} -> {@code fs.s3a.*} / * Jindo {@code fs.oss.*} / etc.), computed upstream by the connector from - * {@code ConnectorContext.getStorageProperties()} via fe-filesystem's + * {@code ConnectorStorageContext.getStorageProperties()} via fe-filesystem's * {@code toHadoopProperties().toHadoopConfigurationMap()} (P1-T03; replaces the legacy * {@code StorageProperties.buildObjectStorageHadoopConfig(props)} call);
  • *
  • {@code paimon.s3.*} / {@code paimon.s3a.*} / {@code paimon.fs.s3.*} / {@code paimon.fs.oss.*} @@ -265,7 +265,7 @@ public static Configuration buildHadoopConfiguration(Map props, * *
      *
    1. the pre-computed {@code storageHadoopConfig} (canonical object-store translation, produced - * upstream from {@code ConnectorContext.getStorageProperties()} via fe-filesystem's + * upstream from {@code ConnectorStorageContext.getStorageProperties()} via fe-filesystem's * {@code toHadoopConfigurationMap()}; replaces the legacy * {@code StorageProperties.buildObjectStorageHadoopConfig(props)} call);
    2. *
    3. the original {@code paimon.s3./s3a./fs.s3./fs.oss.} re-key + raw {@code fs./dfs./hadoop.} @@ -276,7 +276,7 @@ public static Configuration buildHadoopConfiguration(Map props, private static void applyStorageConfig(Map storageHadoopConfig, Map props, BiConsumer setter) { // Pre-computed canonical storage config, assembled by PaimonConnector from - // ctx.getStorageProperties().toHadoopProperties().toHadoopConfigurationMap() (fe-filesystem is the + // getStorageProperties().toHadoopProperties().toHadoopConfigurationMap() (fe-filesystem is the // single source of truth; P1-T03): object stores contribute fs.s3a.*/fs.oss.*/fs.cosn.*/fs.obs.*, // and an HDFS catalog contributes its hadoop.config.resources XML + HA + auth keys (C2; the // fe-filesystem HDFS map is defaults-free so it cannot clobber the object-store keys above). Inline diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java index 184f35f0b4da35..ebe7d4fccaeefa 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java @@ -29,6 +29,7 @@ import org.apache.doris.connector.metastore.spi.JdbcDriverSupport; import org.apache.doris.connector.metastore.spi.MetaStoreProviders; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.kerberos.HadoopAuthenticator; import org.apache.doris.kerberos.KerberosAuthSpec; @@ -333,9 +334,9 @@ public Set getCapabilities() { ConnectorCapability.SUPPORTS_PARTITION_STATS, // Paimon tables are queryable via the generic SQL-driven ExternalAnalysisTask FULL path, so // they opt into background per-column auto-analyze (paimon was never wired into the legacy - // instanceof-based whitelist; this is the parity-neutral mechanism wiring it in). Unlike the - // iceberg capabilities this is NOT inert pre-cutover: paimon is already in SPI_READY_TYPES, so - // paimon background auto-analyze activates on merge (parity-safe — manual ANALYZE already uses + // instanceof-based whitelist; this is the parity-neutral mechanism wiring it in). Paimon is + // already served by its connector plugin, so this is not inert: paimon background + // auto-analyze activates on merge (parity-safe — manual ANALYZE already uses // the same doFull SQL path). NOT SUPPORTS_TOPN_LAZY_MATERIALIZE: paimon was never eligible for // Top-N lazy materialization. ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE, @@ -426,7 +427,7 @@ private Catalog createCatalog() { /** * Assembles the canonical storage Hadoop config from the FE-bound storage properties (P1-T03). * fe-core binds the catalog's raw property map to fe-filesystem {@link StorageProperties} and hands - * them over via {@link ConnectorContext#getStorageProperties()}; here we merge each one's + * them over via {@link ConnectorStorageContext#getStorageProperties()}; here we merge each one's * {@code toHadoopProperties().toHadoopConfigurationMap()}: object stores contribute their * fs.s3a.* / Jindo fs.oss.* / fs.cosn.* / fs.obs.* translation, and an HDFS-backed catalog contributes * its hadoop.config.resources XML + HA + auth keys (C2; the fe-filesystem HDFS Hadoop map is @@ -436,11 +437,11 @@ private Catalog createCatalog() { * used to make. Empty for REST (the server owns storage) and for a catalog with no typed storage (it * reaches the conf via the raw fs./dfs./hadoop. passthrough). */ - // Package-private (not private) so PaimonCatalogFactoryTest can drive the ctx.getStorageProperties() + // Package-private (not private) so PaimonCatalogFactoryTest can drive the storage().getStorageProperties() // -> toHadoopProperties() -> Configuration wiring end-to-end (visible for testing). Map buildStorageHadoopConfig() { Map merged = new HashMap<>(); - for (StorageProperties sp : context.getStorageProperties()) { + for (StorageProperties sp : storage().getStorageProperties()) { sp.toHadoopProperties().ifPresent(h -> merged.putAll(h.toHadoopConfigurationMap())); } return merged; @@ -622,4 +623,9 @@ public void close() throws IOException { } } } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java index 2df695a8f8866e..4a36e8670df442 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java @@ -101,8 +101,8 @@ public class PaimonConnectorMetadata implements ConnectorMetadata { // owning PaimonConnector; null = no cross-query derived layer (the convenience/test ctors used by ~15 // existing direct-construction tests pass null). Layered ABOVE the raw remote catalogOps.listPartitions // call: a hit skips both the derived-view BUILD (collectPartitions) and the remote round-trip, keyed by - // (db, table, snapshotId, schemaId). Consumed by all three partition-enumeration hooks (listPartitions, - // listPartitionNames, listPartitionValues) via the shared cachedPartitions collector -- paimon does not + // (db, table, snapshotId, schemaId). Consumed by both partition-enumeration hooks (listPartitions, + // listPartitionNames) via the shared cachedPartitions collector -- paimon does not // override getMvccPartitionView (see ConnectorMetadata's default), so the generic MTMV model already uses // listPartitions for its LIST/timestamp partition view. private final ConnectorMetadataCache> partitionViewCache; @@ -353,10 +353,6 @@ private ConnectorTableSchema buildTableSchema(String tableName, Table table, Lis // treated as non-partitioned). schemaProps.put(ConnectorTableSchema.PARTITION_COLUMNS_KEY, String.join(",", partitionKeys)); } - if (primaryKeys != null && !primaryKeys.isEmpty()) { - schemaProps.put(ConnectorTableSchema.PRIMARY_KEYS_KEY, String.join(",", primaryKeys)); - } - return new ConnectorTableSchema(tableName, columns, "PAIMON", schemaProps); } @@ -448,11 +444,6 @@ private static boolean isSupportedSysTable(String sysName) { return false; } - @Override - public Map getProperties() { - return Collections.emptyMap(); - } - // ==================== E5: MVCC Snapshots / Time Travel ==================== /** @@ -887,16 +878,11 @@ public void dropTable(ConnectorSession session, ConnectorTableHandle handle) { // ==================== DDL: Create/Drop Database ==================== - @Override - public boolean supportsCreateDatabase() { - return true; - } - /** * Creates a Paimon database. * - *

      fe-core already does the {@code IF NOT EXISTS} short-circuit before reaching here: since - * {@link #supportsCreateDatabase()} is true, {@code PluginDrivenExternalCatalog.createDb} + *

      fe-core already does the {@code IF NOT EXISTS} short-circuit before reaching here: + * {@code PluginDrivenExternalCatalog.createDb} * consults BOTH the FE db-name cache AND the remote {@code databaseExists} and no-ops when the * db already exists, so this body passes {@code ignoreIfExists = false} to the seam (mirrors * {@code MaxComputeConnectorMetadata.createDatabase}). If the db somehow exists, paimon throws @@ -1076,8 +1062,7 @@ public List listPartitionNames(ConnectorSession session, ConnectorTableH * *

      A present {@code filter} BYPASSES the derived cache (computes directly, never populates) — it is * not the pruning path and not keyed by filter. Every other case routes through {@link #cachedPartitions}, - * the shared cache-aware collector this hook shares with {@link #listPartitionNames} and - * {@link #listPartitionValues}. + * the shared cache-aware collector this hook shares with {@link #listPartitionNames}. */ @Override public List listPartitions(ConnectorSession session, @@ -1090,8 +1075,8 @@ public List listPartitions(ConnectorSession session, } /** - * Shared cache-aware partition collector backing the no-filter path of {@link #listPartitions}, - * plus {@link #listPartitionNames} and {@link #listPartitionValues}. Returns the BUILT + * Shared cache-aware partition collector backing the no-filter path of {@link #listPartitions} plus + * {@link #listPartitionNames}. Returns the BUILT * {@code List} from {@link #partitionViewCache} (PERF-06 cache A), keyed by * {@code (db, table, snapshotId, schemaId)} (see {@link #partitionViewCacheKey}) — a hit skips both * {@link #collectPartitions} and the remote {@code catalogOps.listPartitions} round-trip, so repeated @@ -1142,28 +1127,10 @@ private ConnectorTableKey partitionViewCacheKey(PaimonTableHandle paimonHandle) paimonHandle.getDatabaseName(), paimonHandle.getTableName(), snapshotId, -1L); } - @Override - public List> listPartitionValues(ConnectorSession session, - ConnectorTableHandle handle, List partitionColumns) { - List partitions = cachedPartitions((PaimonTableHandle) handle); - List> result = new ArrayList<>(partitions.size()); - for (ConnectorPartitionInfo partition : partitions) { - Map rawValues = partition.getPartitionValues(); - // Preserve the requested partitionColumns order (NOT Paimon's native spec order): - // this feeds the partition_values() TVF whose inner-list order must match the input. - List values = new ArrayList<>(partitionColumns.size()); - for (String column : partitionColumns) { - values.add(rawValues.get(column)); - } - result.add(values); - } - return result; - } - /** * Shared (uncached) partition collector behind {@link #cachedPartitions} — the underlying compute for - * {@link #listPartitionNames}, {@link #listPartitions} and {@link #listPartitionValues}, also reached - * directly on the filter / unpartitioned / null-cache bypass. Replicates the fe-core display-name logic + * {@link #listPartitionNames} and {@link #listPartitions}, also reached directly on the filter / + * unpartitioned / null-cache bypass. Replicates the fe-core display-name logic * ({@code PaimonUtil.generatePartitionInfo} + {@code isLegacyPartitionName}) so the rendered * partition names stay byte-identical to fe-core — including #65904, which drives value order from * the partition columns and escapes path-special characters in the name via the Paimon SDK. @@ -1242,7 +1209,7 @@ private List collectPartitions(PaimonTableHandle paimonH // collide (one partition item silently lost). Parity with fe-core #65904. This same rendered map // is also handed to ConnectorPartitionInfo as the partition VALUE map (below), so the active // partition_values() TVF feeder (PluginDrivenExternalTable.getNameToPartitionValues) reads the - // Hive-canonical rendered form (DATE formatted, genuine-null → HIVE_DEFAULT_PARTITION) instead of + // Hive-canonical rendered form (DATE formatted, genuine-null → NULL_PARTITION_NAME) instead of // paimon's raw spec (DATE=epoch-day, null=__DEFAULT_PARTITION__), which would fail the TVF // (convertStringToDateV2 throws) and mis-render null. Mirrors hive/iceberg, whose value maps // already hold decoded canonical strings. @@ -1260,7 +1227,7 @@ private List collectPartitions(PaimonTableHandle paimonH // The name is still normalized to the Doris-canonical sentinel (partition-name identity is // preserved; the value string is ignored once the flag marks it null). Handled before the // DATE branch so a null DATE partition does not crash on Integer.parseInt("__DEFAULT_PARTITION__"). - rendered = ConnectorPartitionValues.HIVE_DEFAULT_PARTITION; + rendered = ConnectorPartitionValues.NULL_PARTITION_NAME; } else if (legacyName && dateColumns.contains(partitionColumnName)) { // When partition.legacy-name = true (default), Paimon stores DATE as days since // 1970-01-01 (epoch integer), so render it via the Paimon SDK formatDate; when diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java index 6cb05267bd2a6d..5333e622f24b3f 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorProvider.java @@ -29,6 +29,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -58,6 +59,15 @@ public Connector create(Map properties, ConnectorContext context return new PaimonConnector(properties, context); } + /** + * {@code CREATE TABLE ... ENGINE=paimon} keeps working; omitting the clause is equivalent. The engine + * keyword is legacy syntax the connector owns, not the catalog type and not the displayed engine name. + */ + @Override + public Set acceptedCreateTableEngineNames() { + return Collections.singleton("paimon"); + } + /** * Validates catalog properties at CREATE CATALOG time via the shared metastore parsers (P2-T03): * {@link MetaStoreProviders#bind} selects the backend by {@code paimon.catalog.type} and the bound diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java index 16f0a1df934f32..5424ad8e634eb7 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java @@ -155,10 +155,24 @@ private Predicate convertComparison(ConnectorComparison cmp) { } Object value = convertLiteralValue(literal, fieldTypes.get(idx)); if (value == null) { + // A null value here means one of two unrelated things, and conflating them is what caused + // `col <=> 5` to be pushed as IS NULL: either the literal really IS null, or this Paimon + // type is deliberately not pushed down (FLOAT / CHAR / timestamp with local time zone). + // Only the first case has a translation - and only for the null-safe operator. Checking + // the operator alone would resurrect the same bug on a FLOAT column. + if (cmp.getOperator() == ConnectorComparison.Operator.EQ_FOR_NULL && literal.isNull()) { + return builder.isNull(idx); + } return null; } switch (cmp.getOperator()) { case EQ: + case EQ_FOR_NULL: + // Against a NON-null literal, `col <=> v` and `col = v` have identical result sets: + // <=> yields false (never unknown) when col is null, and Paimon's Equal likewise never + // matches nulls. Translating this to IS NULL - as the port from fe-core did - is not a + // narrowing but an inversion: Paimon prunes away every file that holds col = v, and the + // BE-side residual filter can only remove rows, never bring pruned files back. return builder.equal(idx, value); case NE: return builder.notEqual(idx, value); @@ -170,8 +184,6 @@ private Predicate convertComparison(ConnectorComparison cmp) { return builder.greaterThan(idx, value); case GE: return builder.greaterOrEqual(idx, value); - case EQ_FOR_NULL: - return builder.isNull(idx); default: return null; } @@ -231,11 +243,50 @@ private Predicate convertLike(ConnectorLike like) { return null; } String pattern = ((ConnectorLiteral) patternExpr).getValue().toString(); - if (!pattern.startsWith("%") && pattern.endsWith("%")) { - String prefix = pattern.substring(0, pattern.length() - 1); - return builder.startsWith(idx, BinaryString.fromString(prefix)); + String prefix = literalPrefixOrNull(pattern); + if (prefix == null) { + return null; } - return null; + return builder.startsWith(idx, BinaryString.fromString(prefix)); + } + + /** + * The literal prefix a Doris LIKE pattern is exactly equivalent to, or {@code null} when no such + * proof exists. + * + *

      Declining is always safe - the predicate is simply not pushed and BE filters every row with + * the original LIKE. Narrowing is not: the predicate returned here drives Paimon's partition and + * data-file pruning at planning time and the BE-side JNI row filter, so a file skipped because the + * pushed prefix was stricter than the user's pattern can never be read back. + * + *

      Doris LIKE uses backslash as its default escape character, {@code %} matches any run of + * characters and {@code _} matches exactly one. Only {@code literal%} is provably a prefix match: + *

        + *
      • {@code _} anywhere is a wildcard, so {@code 'a_c%'} must also match {@code abc...};
      • + *
      • a backslash escapes the next character, so the raw text is not the literal to match + * ({@code 'a\%%'} means "starts with a%", not "starts with a\%"). Rejecting the whole + * pattern on any backslash also guarantees the {@code %} we strip below is a real wildcard + * and not an escaped literal one;
      • + *
      • a {@code %} left anywhere but the tail means the rest is not a literal prefix.
      • + *
      + */ + private static String literalPrefixOrNull(String pattern) { + if (pattern.indexOf('_') >= 0 || pattern.indexOf('\\') >= 0) { + return null; + } + int end = pattern.length(); + while (end > 0 && pattern.charAt(end - 1) == '%') { + end--; + } + if (end == pattern.length()) { + // No trailing '%': the pattern is anchored at both ends (or starts with '%'), not a prefix. + return null; + } + String body = pattern.substring(0, end); + if (body.isEmpty() || body.indexOf('%') >= 0) { + return null; + } + return body; } /** diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanMetrics.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanMetrics.java index d3ec508edd81c0..7cccdace5db632 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanMetrics.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanMetrics.java @@ -44,7 +44,11 @@ public final class PaimonScanMetrics { private static final long MINUTE_MS = 60 * SECOND_MS; private static final long HOUR_MS = 60 * MINUTE_MS; - /** Profile group name — MUST equal fe-core {@code SummaryProfile.PAIMON_SCAN_METRICS} (display ordering). */ + /** + * Profile group name. Connector-chosen and self-contained: the engine get-or-creates a profile child under + * this name, so it needs no prior registration in fe-core. It IS user-visible in the query profile, hence + * pinned by a test. + */ public static final String GROUP_NAME = "Paimon Scan Metrics"; private PaimonScanMetrics() { diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java index 1bc72b195cdd84..6933e0cc3e44e0 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java @@ -24,8 +24,11 @@ import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanProfile; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.doris.connector.metastore.spi.JdbcDriverSupport; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.properties.StorageProperties; import org.apache.doris.thrift.TColumnType; import org.apache.doris.thrift.TFileScanRangeParams; @@ -190,19 +193,9 @@ public class PaimonScanPlanProvider implements ConnectorScanPlanProvider { // also pushed into history_schema_info under this key (PaimonScanNode.doInitialize -> -1L). private static final long CURRENT_SCHEMA_ID = -1L; - // FIX-E (explain gap): synthetic node-property keys the generic PluginDrivenScanNode injects into - // the props map it passes to appendExplainInfo, carrying the per-scan native/total split counts it - // accumulated from ConnectorScanRange.isNativeReadRange(). They are NOT real connector properties - // (never sent to BE) — only this provider's appendExplainInfo reads them, to re-emit the legacy - // PaimonScanNode "paimonNativeReadSplits=/" line. Keys are byte-identical to the - // PluginDrivenScanNode constants so the inject/consume sides stay in lockstep. - private static final String NATIVE_READ_SPLITS_KEY = "__native_read_splits"; - private static final String TOTAL_READ_SPLITS_KEY = "__total_read_splits"; - // FIX-E (explain gap): present (="true") only when the generic PluginDrivenScanNode renders a VERBOSE - // EXPLAIN. Gates the per-split "PaimonSplitStats:" block below to VERBOSE, mirroring the legacy - // PaimonScanNode (which emitted the block only under TExplainLevel.VERBOSE). Byte-identical to the - // PluginDrivenScanNode constant so the inject/consume sides stay in lockstep. - private static final String VERBOSE_EXPLAIN_KEY = "__explain_verbose"; + // Connector-private scan node property key (the engine never reads it): carries the base64-serialized + // paimon Table from getScanNodeProperties to populateScanLevelParams, which puts it on the thrift. + private static final String PROP_SERIALIZED_TABLE = "paimon.serialized_table"; private final Map properties; private final PaimonCatalogOps catalogOps; @@ -325,12 +318,10 @@ Table resolveScanTable(PaimonTableHandle paimonHandle) { } @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - return planScanInternal(session, handle, columns, filter, false); + public boolean supportsFileCache() { + // paimon reads native parquet/orc sub-splits where it can (see isNativeReadRange), so the BE file cache + // applies; this preserves the governance paimon catalogs already had. + return true; } /** @@ -413,21 +404,15 @@ public void releaseReadTransaction(String queryId) { } /** - * COUNT(*)-pushdown-aware scan entry (FIX-COUNT-PUSHDOWN). The generic {@code PluginDrivenScanNode} - * forwards the no-grouping {@code COUNT(*)} signal here via the SPI's count-pushdown overload. - * {@code limit} and {@code requiredPartitions} are not consumed by the paimon read path (same as - * the other overloads, whose defaults fold down to the 4-arg {@code planScan}). + * The scan entry. Of everything on the request, paimon consumes the handle, the columns, the filter and + * the no-grouping {@code COUNT(*)} signal (FIX-COUNT-PUSHDOWN, which lets a split answer from its + * precomputed merged row count); the row limit and the pruned partition set are not consumed by the + * paimon read path — it is predicate-driven and re-plans through the SDK from the filter. */ @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit, - List requiredPartitions, - boolean countPushdown) { - return planScanInternal(session, handle, columns, filter, countPushdown); + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + return planScanInternal(session, request.getTableHandle(), request.getColumns(), + request.getFilter(), request.isCountPushdown()); } /** @@ -704,14 +689,14 @@ List buildNativeRanges(RawFile file, DeletionFile deletionFile, * file factory only recognizes {@code s3://}, so an un-normalized OSS/COS/OBS path fails the * native read (data file) or silently drops the deletion vector (merge-on-read wrong rows). The * connector cannot import fe-core's {@code LocationPath}, so it delegates to the - * {@link ConnectorContext#normalizeStorageUri(String, Map)} seam, passing the per-table + * {@link ConnectorStorageContext#normalizeStorageUri(String, Map)} seam, passing the per-table * {@code vendedToken} (empty for non-REST) so a REST object-store path normalizes via the vended * credentials — the catalog's static storage map is empty for REST, so the static-only path would * throw (FIX-REST-VENDED-URI-NORMALIZE). With no context (offline unit tests) the raw path is * preserved — same null-guard as the {@code vendStorageCredentials} overlay below. */ private String normalizeUri(String rawUri, Map vendedToken) { - return context != null ? context.normalizeStorageUri(rawUri, vendedToken) : rawUri; + return context != null ? storage().normalizeStorageUri(rawUri, vendedToken) : rawUri; } @Override @@ -727,7 +712,7 @@ public Map getScanNodeProperties( Map props = new LinkedHashMap<>(); // File format type (default) - props.put("file_format_type", "jni"); + props.put(ScanNodePropertyKeys.FILE_FORMAT_TYPE, "jni"); props.put("table_format_type", "paimon"); // Path partition keys: declare the partition columns at the scan-node level so @@ -741,12 +726,12 @@ public Map getScanNodeProperties( // parity (and mirrors the hive connector). PluginDrivenScanNode.getPathPartitionKeys reads this. List partitionKeys = table.partitionKeys(); if (partitionKeys != null && !partitionKeys.isEmpty()) { - props.put("path_partition_keys", String.join(",", partitionKeys)); + props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS, String.join(",", partitionKeys)); } // Serialized table for BE's JNI reader String serializedTable = encodeObjectToString(table); - props.put("paimon.serialized_table", serializedTable); + props.put(PROP_SERIALIZED_TABLE, serializedTable); // Serialized predicates for BE's JNI scanner. ALWAYS emit, even for the no-filter / empty-predicate // case: an empty list still serializes to a non-null base64 string, and PaimonJniScanner.getPredicates() @@ -784,7 +769,7 @@ public Map getScanNodeProperties( // canonical keys, so the raw catalog aliases (s3.access_key, oss.access_key, …) must be translated // before they leave FE — copying them verbatim gives the native reader no usable creds (403 on a // private bucket). Sourced from the typed fe-filesystem StorageProperties bound by fe-core and - // handed over via ctx.getStorageProperties() (P1-T04): each backend's toBackendProperties().toMap() + // handed over via storage().getStorageProperties() (P1-T04): each backend's toBackendProperties().toMap() // yields the canonical map (e.g. S3FileSystemProperties IS-A BackendStorageProperties → AWS_*). // This replaces the legacy getBackendStorageProperties() seam so the connector derives BOTH its // Hadoop config (P1-T03) and its BE creds from the SAME typed source (design D-003). Empty when no @@ -799,11 +784,11 @@ public Map getScanNodeProperties( // tracked as a follow-up; only affects OSS/COS/OBS catalogs with no static ak/sk. if (context != null) { Map backendStorageProps = new HashMap<>(); - for (StorageProperties sp : context.getStorageProperties()) { + for (StorageProperties sp : storage().getStorageProperties()) { sp.toBackendProperties().ifPresent(b -> backendStorageProps.putAll(b.toMap())); } for (Map.Entry e : backendStorageProps.entrySet()) { - props.put("location." + e.getKey(), e.getValue()); + props.put(ScanNodePropertyKeys.LOCATION_PREFIX + e.getKey(), e.getValue()); } } @@ -813,9 +798,9 @@ public Map getScanNodeProperties( // import fe-core's StorageProperties). Vended overlays static (legacy precedence). Skipped // when no context (offline unit tests) or the table is non-REST (empty token -> no-op). if (context != null) { - Map vendedBeProps = context.vendStorageCredentials(extractVendedToken(table)); + Map vendedBeProps = storage().vendStorageCredentials(extractVendedToken(table)); for (Map.Entry e : vendedBeProps.entrySet()) { - props.put("location." + e.getKey(), e.getValue()); + props.put(ScanNodePropertyKeys.LOCATION_PREFIX + e.getKey(), e.getValue()); } } @@ -1318,6 +1303,15 @@ private static Optional getFileFormatBySuffix(String path) { @Override public void populateScanLevelParams(TFileScanRangeParams params, Map properties) { + // The paimon Table the BE JNI reader deserializes. Set here rather than through a dedicated SPI + // method: this hook already receives the very TFileScanRangeParams the engine sends, and it runs + // after the generic scan-range construction, so a plain set is enough. BE fails the scan outright + // when the field is missing ("missing serialized_table"), so it must be emitted for every scan. + String serializedTable = properties.get(PROP_SERIALIZED_TABLE); + if (serializedTable != null) { + params.setSerializedTable(serializedTable); + } + String predicate = properties.get("paimon.predicate"); if (predicate != null) { params.setPaimonPredicate(predicate); @@ -1348,7 +1342,8 @@ public void populateScanLevelParams(TFileScanRangeParams params, * {@code paimonNativeReadSplits=/} (native ORC/Parquet sub-splits over all splits). * The generic {@code PluginDrivenScanNode} accumulates the counts from * {@link ConnectorScanRange#isNativeReadRange()} in {@code getSplits} and injects them into the - * props map via the {@link #NATIVE_READ_SPLITS_KEY}/{@link #TOTAL_READ_SPLITS_KEY} synthetic keys, + * props map via the {@link ScanNodePropertyKeys#SYNTHETIC_NATIVE_READ_SPLITS} / + * {@link ScanNodePropertyKeys#SYNTHETIC_TOTAL_READ_SPLITS} synthetic keys, * so this connector owns the paimon-specific string without an SPI signature change. Skipped when * the keys are absent (e.g. EXPLAIN rendered before any split accounting, or another connector's * props map) so the line never prints {@code 0/0} spuriously. @@ -1356,8 +1351,8 @@ public void populateScanLevelParams(TFileScanRangeParams params, @Override public void appendExplainInfo(StringBuilder output, String prefix, Map nodeProperties) { - String nativeSplits = nodeProperties.get(NATIVE_READ_SPLITS_KEY); - String totalSplits = nodeProperties.get(TOTAL_READ_SPLITS_KEY); + String nativeSplits = nodeProperties.get(ScanNodePropertyKeys.SYNTHETIC_NATIVE_READ_SPLITS); + String totalSplits = nodeProperties.get(ScanNodePropertyKeys.SYNTHETIC_TOTAL_READ_SPLITS); if (nativeSplits != null && totalSplits != null) { output.append(prefix).append("paimonNativeReadSplits=") .append(nativeSplits).append("/").append(totalSplits).append("\n"); @@ -1373,7 +1368,7 @@ public void appendExplainInfo(StringBuilder output, String prefix, if (encodedPredicates != null) { appendPredicatesFromPaimon(output, prefix, encodedPredicates); } - if (nodeProperties.containsKey(VERBOSE_EXPLAIN_KEY)) { + if (nodeProperties.containsKey(ScanNodePropertyKeys.SYNTHETIC_EXPLAIN_VERBOSE)) { appendSplitStats(output, prefix, Integer.parseInt(nativeSplits), Integer.parseInt(totalSplits)); } @@ -1723,11 +1718,6 @@ private static TField buildField(DataType dataType) { return field; } - @Override - public String getSerializedTable(Map properties) { - return properties.get("paimon.serialized_table"); - } - /** * Serializes a paimon {@link Split} for the BE JNI reader: ALWAYS Java object serialization, which is * what BE's PaimonJniScanner deserializes. Mirrors upstream {@code PaimonScanNode.setPaimonParams} + @@ -1753,4 +1743,9 @@ private static String encodeObjectToString(T obj) { private static String escapeJson(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); } + + /** This catalog's engine-owned storage services (see {@link ConnectorContext#getStorageContext()}). */ + private ConnectorStorageContext storage() { + return context.getStorageContext(); + } } diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java index 9df47c78d21b52..e6097aae4f5920 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java @@ -18,7 +18,6 @@ package org.apache.doris.connector.paimon; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileFormatType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TPaimonDeletionFileDesc; @@ -105,11 +104,6 @@ private PaimonScanRange(Builder builder) { this.properties = Collections.unmodifiableMap(props); } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Optional getPath() { return Optional.ofNullable(path); @@ -267,11 +261,12 @@ public void populateRangeParams(TTableFormatFileDesc formatDesc, // (PaimonScanPlanProvider.serializePartitionValue) returns Java null for a genuine // null and the literal toString() otherwise — a null is never a Hive directory // sentinel. So derive isNull from the Java null ONLY, matching legacy - // PaimonScanNode.setScanParams (source/PaimonScanNode.java:323-326). Do NOT route - // through ConnectorPartitionValues.normalize: its __HIVE_DEFAULT_PARTITION__/"\N" - // coercion is correct for hudi (path-encoded partitions) but here would turn a - // genuine literal partition value of "\N" or "__HIVE_DEFAULT_PARTITION__" into SQL - // NULL. BE ignores the rendered string when isNull=true, so "" matches legacy. + // PaimonScanNode.setScanParams (source/PaimonScanNode.java:323-326). Do NOT reuse hudi's + // directory-name rule (HudiScanRange.populateRangeParams): its + // __HIVE_DEFAULT_PARTITION__/"\N" coercion is correct for path-encoded partitions but + // here would turn a genuine literal partition value of "\N" or + // "__HIVE_DEFAULT_PARTITION__" into SQL NULL. BE ignores the rendered string when + // isNull=true, so "" matches legacy. String value = entry.getValue(); pathKeys.add(entry.getKey()); pathValues.add(value != null ? value : ""); diff --git a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.java b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.java index 8dbb4e856318bd..aa12b0628454e5 100644 --- a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.java +++ b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.java @@ -17,23 +17,21 @@ package org.apache.doris.connector.paimon; -import org.apache.doris.connector.api.Connector; -import org.apache.doris.connector.api.ConnectorHttpSecurityHook; -import org.apache.doris.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; -import org.apache.doris.connector.spi.ConnectorMetaInvalidator; -import org.apache.doris.filesystem.properties.StorageProperties; +import org.apache.doris.connector.spi.ForwardingConnectorContext; import org.apache.doris.kerberos.HadoopAuthenticator; -import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.concurrent.Callable; import java.util.function.Supplier; /** * A {@link ConnectorContext} decorator that wraps every {@link #executeAuthenticated} call, then delegates to - * the wrapped engine context. Every other method is a pure pass-through. The paimon analogue of the iceberg + * the wrapped engine context. Every other method is forwarded verbatim by + * {@link ForwardingConnectorContext} — which is the point of extending it rather than implementing the + * interface and copying each method by hand: a missed pass-through would not fail to compile, it would + * quietly land on the interface default (a silent downgrade) instead of the engine. The paimon analogue of the + * iceberg * connector's {@code TcclPinningConnectorContext}, wrapping the single FE-injected context once covers every * remote read/DDL/commit ({@code PaimonConnectorMetadata} routes them all through * {@link ConnectorContext#executeAuthenticated}). @@ -60,15 +58,14 @@ *

      Note: paimon has no live Kerberos regression suite, so this is verified by wiring/static reasoning; the * end-to-end gate is the iceberg Kerberos suite, which exercises the identical mechanism. */ -final class TcclPinningConnectorContext implements ConnectorContext { +final class TcclPinningConnectorContext extends ForwardingConnectorContext { - private final ConnectorContext delegate; private final ClassLoader pluginClassLoader; private final Supplier pluginAuthenticator; TcclPinningConnectorContext(ConnectorContext delegate, ClassLoader pluginClassLoader, Supplier pluginAuthenticator) { - this.delegate = Objects.requireNonNull(delegate, "delegate"); + super(delegate); this.pluginClassLoader = Objects.requireNonNull(pluginClassLoader, "pluginClassLoader"); this.pluginAuthenticator = Objects.requireNonNull(pluginAuthenticator, "pluginAuthenticator"); } @@ -81,7 +78,7 @@ public T executeAuthenticated(Callable task) throws Exception { HadoopAuthenticator auth = pluginAuthenticator.get(); if (auth == null) { // Non-Kerberos: keep the FE-injected auth path exactly as-is. - return delegate.executeAuthenticated(task); + return delegate().executeAuthenticated(task); } // Kerberos: the connector is the sole authenticator. Run the op under the PLUGIN's UGI copy (the // one the plugin's FileSystem reads); do NOT also invoke the FE-injected app-side authenticator. @@ -91,90 +88,11 @@ public T executeAuthenticated(Callable task) throws Exception { } } - // ----- pure delegation ----- - - @Override - public String getCatalogName() { - return delegate.getCatalogName(); - } - - @Override - public long getCatalogId() { - return delegate.getCatalogId(); - } - - @Override - public Map getEnvironment() { - return delegate.getEnvironment(); - } - - @Override - public ConnectorHttpSecurityHook getHttpSecurityHook() { - return delegate.getHttpSecurityHook(); - } - - @Override - public String sanitizeJdbcUrl(String jdbcUrl) { - return delegate.sanitizeJdbcUrl(jdbcUrl); - } - - @Override - public ConnectorMetaInvalidator getMetaInvalidator() { - return delegate.getMetaInvalidator(); - } - - @Override - public Connector createSiblingConnector(String catalogType, Map properties) { - // Delegate to the raw engine context (not this wrapper): the sibling connector applies its OWN - // TCCL/auth pinning over the context it is handed, so it must receive the unwrapped context to avoid - // double-pinning to this plugin's loader. Keeps this decorator a true exhaustive pass-through. - return delegate.createSiblingConnector(catalogType, properties); - } - - @Override - public Map vendStorageCredentials(Map rawVendedCredentials) { - return delegate.vendStorageCredentials(rawVendedCredentials); - } - - @Override - public String normalizeStorageUri(String rawUri) { - return delegate.normalizeStorageUri(rawUri); - } - - @Override - public String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { - return delegate.normalizeStorageUri(rawUri, rawVendedCredentials); - } - - @Override - public String getBackendFileType(String rawUri, Map rawVendedCredentials) { - return delegate.getBackendFileType(rawUri, rawVendedCredentials); - } - - @Override - public List getBrokerAddresses() { - return delegate.getBrokerAddresses(); - } - - @Override - public Map getBackendStorageProperties() { - return delegate.getBackendStorageProperties(); - } - - @Override - public List getStorageProperties() { - return delegate.getStorageProperties(); - } - - @Override - public void testBackendStorageConnectivity(int storageBackendTypeValue, - Map backendProperties) throws Exception { - // No TCCL pin: this runs entirely engine-side (backend registry + thrift), never in plugin code. - delegate.testBackendStorageConnectivity(storageBackendTypeValue, backendProperties); - } - - @Override - public void cleanupEmptyManagedLocation(String location, List tableChildDirs) { - delegate.cleanupEmptyManagedLocation(location, tableChildDirs); - } + // Every other method is forwarded by ForwardingConnectorContext. Only methods that must run + // under the plugin loader (or under the plugin's own authenticator) belong here. + // + // createSiblingConnector deliberately reaches the RAW engine context rather than this wrapper: + // the sibling applies its own TCCL/auth pinning to whatever context it is handed, so handing it a + // context already pinned to THIS plugin's loader would pin it to the wrong plugin. The base class + // forwards to the wrapped context, which is exactly that. } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java index 8aa147063b06b7..2d8e44ce26d54b 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonCatalogFactoryTest.java @@ -49,7 +49,7 @@ *

      P1-T03: the canonical object-store translation ({@code s3.*}/{@code oss.*}/... -> {@code fs.s3a.*}) * moved OUT of this factory to fe-filesystem; the builders now receive it pre-computed as a * {@code storageHadoopConfig} map (what {@code PaimonConnector} assembles from - * {@code ConnectorContext.getStorageProperties().toHadoopConfigurationMap()}). These tests therefore + * {@code ConnectorStorageContext.getStorageProperties().toHadoopConfigurationMap()}). These tests therefore * pin the connector-LOCAL contract — storage-map overlay, {@code paimon.*} re-key, raw * {@code fs./dfs./hadoop.} passthrough, last-write-wins, kerberos-after-storage — NOT the canonical * translation, which is owned and tested by fe-filesystem's {@code *FileSystemPropertiesTest}. The @@ -210,7 +210,7 @@ public void restBuildOptionsOmitsBlankWarehouse() { // --------------------------------------------------------------------- // buildHadoopConfiguration — storage-config overlay + paimon.* re-key + raw passthrough // (P1-T03: the canonical object-store translation now arrives pre-computed in storageHadoopConfig - // from ConnectorContext.getStorageProperties(); the connector-local overlay/last-write-wins stays) + // from ConnectorStorageContext.getStorageProperties(); the connector-local overlay/last-write-wins stays) // --------------------------------------------------------------------- @Test @@ -254,7 +254,7 @@ public void buildHadoopConfigurationAppliesStorageHadoopConfig() { "fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem")); // WHY (P1-T03): the canonical object-store config (fs.s3a.* etc.) now arrives PRE-COMPUTED in - // storageHadoopConfig — assembled by PaimonConnector from ConnectorContext.getStorageProperties() + // storageHadoopConfig — assembled by PaimonConnector from ConnectorStorageContext.getStorageProperties() // via fe-filesystem's toHadoopConfigurationMap() — and the connector overlays it verbatim. Before // P1-T03 the connector recomputed it from props via the legacy buildObjectStorageHadoopConfig. // MUTATION: not applying storageHadoopConfig (fs.s3a.access.key null) -> red. @@ -295,7 +295,7 @@ public void buildHadoopConfigurationPaimonPrefixOverridesStorageConfig() { public void buildStorageHadoopConfigFoldsInHdfsHadoopMap() { // C2 end-to-end seam: a storage property exposing a Hadoop-config key that is NOT a raw catalog // prop (so it cannot ride the connector's fs./dfs./hadoop. passthrough) must reach the FE catalog - // Configuration via ctx.getStorageProperties().toHadoopProperties() -> buildStorageHadoopConfig -> + // Configuration via getStorageProperties().toHadoopProperties() -> buildStorageHadoopConfig -> // buildHadoopConfiguration. This is exactly the leg the HDFS C2 fix relies on: after the fix // HdfsFileSystemProperties.toHadoopProperties() is non-empty and carries its hadoop.config.resources // XML keys. MUTATION: dropping the toHadoopProperties() merge in buildStorageHadoopConfig -> red. diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDbDdlTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDbDdlTest.java index 846b71818095d2..45d9bc1b612e01 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDbDdlTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDbDdlTest.java @@ -28,8 +28,8 @@ import java.util.Map; /** - * T14 database-DDL tests for {@link PaimonConnectorMetadata#supportsCreateDatabase}, - * {@link #createDatabase} and the 4-arg {@link #dropDatabase}, pinning: + * T14 database-DDL tests for {@link PaimonConnectorMetadata#createDatabase} and the 4-arg + * {@link PaimonConnectorMetadata#dropDatabase}, pinning: * (1) the HMS-only-props gate runs as a pure local arg check BEFORE the authenticator, * (2) raw paimon checked exceptions are wrapped as {@link DorisConnectorException}, * (3) D7=B: every remote call runs INSIDE @@ -61,20 +61,6 @@ private static Map dbProps() { return props; } - // ==================== supportsCreateDatabase ==================== - - @Test - public void supportsCreateDatabaseIsTrue() { - RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); - RecordingConnectorContext ctx = new RecordingConnectorContext(); - - // WHY: supportsCreateDatabase()==true is the gate that makes - // PluginDrivenExternalCatalog.createDb run the remote IF-NOT-EXISTS precheck AND route to - // createDatabase; if it were false, CREATE DATABASE would fall through to "not supported". - // MUTATION: returning false (the SPI default) makes this red and breaks the FE routing. - Assertions.assertTrue(filesystemMetadata(ops, ctx).supportsCreateDatabase()); - } - // ==================== createDatabase: HMS-only-props gate ==================== @Test diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDdlTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDdlTest.java index d1993f705802d0..56183224ddae7e 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDdlTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataDdlTest.java @@ -59,8 +59,7 @@ private static ConnectorCreateTableRequest request(boolean ifNotExists) { ConnectorPartitionSpec partitionSpec = new ConnectorPartitionSpec( ConnectorPartitionSpec.Style.IDENTITY, Collections.singletonList( - new ConnectorPartitionField("id", "identity", Collections.emptyList())), - Collections.emptyList()); + new ConnectorPartitionField("id", "identity", Collections.emptyList()))); Map props = new HashMap<>(); props.put("primary-key", "id"); return ConnectorCreateTableRequest.builder() @@ -81,8 +80,7 @@ private static ConnectorCreateTableRequest requestWithNonIdentityPartition() { ConnectorPartitionSpec partitionSpec = new ConnectorPartitionSpec( ConnectorPartitionSpec.Style.TRANSFORM, Collections.singletonList( - new ConnectorPartitionField("id", "bucket", Collections.singletonList(16))), - Collections.emptyList()); + new ConnectorPartitionField("id", "bucket", Collections.singletonList(16)))); return ConnectorCreateTableRequest.builder() .dbName("db1") .tableName("t1") diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionTest.java index f949a06940bf9f..0d379dff28bbd6 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionTest.java @@ -124,10 +124,10 @@ public void legacyNameTrueRendersDateKeyAndCarriesStats() { @Test public void partitionValueMapCarriesRenderedValuesForTvf() { // WHY: partition_values() reads ConnectorPartitionInfo.getPartitionValues() BY REMOTE NAME and feeds - // it to a consumer that parses DATE via convertStringToDateV2 and maps HIVE_DEFAULT_PARTITION -> SQL + // it to a consumer that parses DATE via convertStringToDateV2 and maps NULL_PARTITION_NAME -> SQL // NULL. So the value MAP (not just orderedValues) must carry the Hive-canonical rendered form: a // formatted date (never the raw epoch-day, which throws and fails the whole TVF), and - // HIVE_DEFAULT_PARTITION for a genuine null (never paimon's raw "__DEFAULT_PARTITION__", which the + // NULL_PARTITION_NAME for a genuine null (never paimon's raw "__DEFAULT_PARTITION__", which the // consumer would render as a literal string instead of SQL NULL). This pins the TVF contract that // passing the raw spec verbatim previously violated. // MUTATION: passing `spec` as the ConnectorPartitionInfo value map -> dt="19723" and null @@ -152,7 +152,7 @@ public void partitionValueMapCarriesRenderedValuesForTvf() { Assertions.assertEquals(DateTimeUtils.formatDate(DT_EPOCH_DAY), infos.get(0).getPartitionValues().get("dt")); - Assertions.assertEquals(ConnectorPartitionValues.HIVE_DEFAULT_PARTITION, + Assertions.assertEquals(ConnectorPartitionValues.NULL_PARTITION_NAME, infos.get(1).getPartitionValues().get("dt")); } @@ -208,33 +208,6 @@ public void legacyNameFalseDoesNotRenderDateKey() { Assertions.assertEquals(Collections.singletonList("dt=2024-01-01/region=cn"), names); } - @Test - public void listPartitionValuesUsesRequestedColumnOrderWithRenderedValues() { - RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); - FakePaimonTable table = new FakePaimonTable( - "t1", dtRegionRowType(), Arrays.asList("dt", "region"), Collections.emptyList()); - table.setOptions(Collections.singletonMap("partition.legacy-name", "true")); - ops.table = table; - // Paimon native spec order is dt, region; the request asks for the reversed order. - Map spec = new LinkedHashMap<>(); - spec.put("dt", String.valueOf(DT_EPOCH_DAY)); - spec.put("region", "cn"); - ops.partitions = Collections.singletonList(partition(spec, 1L, 1L, 1L)); - - List> values = metadataWith(ops) - .listPartitionValues(null, dtRegionHandle(table), Arrays.asList("region", "dt")); - - // WHY: the partition_values() TVF contract requires the inner list order to match the - // REQUESTED partitionColumns order (region, dt), NOT Paimon's native spec order (dt, region); and - // each value is the Hive-canonical RENDERED form (dt -> the formatted date, never the raw epoch-day - // int) so the TVF consumer can parse it (convertStringToDateV2 throws on "19723"). MUTATION: - // iterating spec.entrySet()/keySet() instead of partitionColumns -> ["2024-01-01", "cn"] instead of - // ["cn", "2024-01-01"] -> red; emitting the raw epoch-day "19723" instead of the rendered date -> red. - Assertions.assertEquals( - Collections.singletonList(Arrays.asList("cn", DateTimeUtils.formatDate(DT_EPOCH_DAY))), - values); - } - /** Single STRING partition column {@code category}. */ private static RowType categoryRowType() { return RowType.builder() @@ -271,7 +244,7 @@ public void nullPartitionValueRendersHiveDefaultSentinel() { // string "__DEFAULT_PARTITION__" and IS NULL prunes it away -> empty result, the bug this fixes). // MUTATION: appending the raw spec value "__DEFAULT_PARTITION__" -> name diverges -> red. Assertions.assertEquals( - Collections.singletonList("category=" + ConnectorPartitionValues.HIVE_DEFAULT_PARTITION), + Collections.singletonList("category=" + ConnectorPartitionValues.NULL_PARTITION_NAME), names); } @@ -304,7 +277,7 @@ public void customDefaultPartitionNameIsHonoredAndOtherValuesUntouched() { // treated as null -> red. Assertions.assertEquals( Arrays.asList( - "category=" + ConnectorPartitionValues.HIVE_DEFAULT_PARTITION, + "category=" + ConnectorPartitionValues.NULL_PARTITION_NAME, "category=__DEFAULT_PARTITION__"), names); } @@ -331,7 +304,7 @@ public void nullDatePartitionRendersSentinelInsteadOfCrashing() { // branch first -> NumberFormatException -> red. Assertions.assertEquals( Collections.singletonList( - "dt=" + ConnectorPartitionValues.HIVE_DEFAULT_PARTITION + "/region=cn"), + "dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME + "/region=cn"), names); } @@ -363,7 +336,7 @@ public void nullPartitionSuppliesNullFlagTrue() { Assertions.assertEquals(Collections.singletonList(false), infos.get(1).getPartitionValueNullFlags(), "ordinary value -> isNull flag false"); // The name is still normalized to the sentinel (partition-name identity preserved). - Assertions.assertEquals("category=" + ConnectorPartitionValues.HIVE_DEFAULT_PARTITION, + Assertions.assertEquals("category=" + ConnectorPartitionValues.NULL_PARTITION_NAME, infos.get(0).getPartitionName()); } @@ -382,13 +355,11 @@ public void nonPartitionedHandleReturnsEmptyWithoutSeamCall() { PaimonConnectorMetadata metadata = metadataWith(ops); // WHY: legacy never lists partitions for unpartitioned tables (PaimonPartitionInfoLoader - // returns EMPTY when partitionColumns is empty). All three SPI methods must short-circuit + // returns EMPTY when partitionColumns is empty). Both SPI methods must short-circuit // to empty BEFORE touching the catalog seam. MUTATION: removing the empty-partitionKeys // guard -> a listPartitions seam call is logged -> red. Assertions.assertTrue(metadata.listPartitionNames(null, handle).isEmpty()); Assertions.assertTrue(metadata.listPartitions(null, handle, Optional.empty()).isEmpty()); - Assertions.assertTrue( - metadata.listPartitionValues(null, handle, Collections.singletonList("id")).isEmpty()); Assertions.assertFalse(ops.log.contains("listPartitions:db1.t1"), "unpartitioned tables must not reach the listPartitions seam"); } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java index 4c36d42053e4ce..5a702feafe084e 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorMetadataPartitionViewCacheTest.java @@ -39,7 +39,7 @@ /** * PERF-06 tests for the cross-query DERIVED partition-view cache ("cache A", the generic * {@link ConnectorMetadataCache}) wired into all three partition-enumeration hooks - * ({@link PaimonConnectorMetadata#listPartitions}, {@code listPartitionNames}, {@code listPartitionValues}) + * ({@link PaimonConnectorMetadata#listPartitions}, {@code listPartitionNames}) * via the shared {@code cachedPartitions} collector. Paimon does NOT override {@code getMvccPartitionView} * (the generic MTMV model falls back to its default listPartitions/LIST/timestamp path), so — unlike * iceberg's two typed fields — there is a single typed cache field, now shared by all three hooks. @@ -285,34 +285,12 @@ public void listPartitionNamesCachesAcrossQueries() { } @Test - public void listPartitionValuesCachesAcrossQueries() { - // WHY (PA-1): same as above for the partition_values() TVF path -- it re-rendered on every call before. - // MUTATION: listPartitionValues calling collectPartitions directly -> loadCount 2 -> red. - RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); - FakePaimonTable table = regionTable(); - ops.table = table; - ops.latestSnapshotId = OptionalLong.of(100L); - ops.partitions = Arrays.asList(partition("cn"), partition("us")); - ConnectorMetadataCache> cache = partitionViewCache(); - PaimonConnectorMetadata md = metadataWithCache(ops, cache); - PaimonTableHandle h = handle(table); - - List cols = Collections.singletonList("region"); - List> first = md.listPartitionValues(null, h, cols); - List> second = md.listPartitionValues(null, h, cols); - - Assertions.assertEquals( - Arrays.asList(Collections.singletonList("cn"), Collections.singletonList("us")), first); - Assertions.assertEquals(first, second, "the cached list drives identical values"); - Assertions.assertEquals(1, loadCount(ops), "listPartitionValues must hit the shared cache (enumerate once)"); - } - - @Test - public void allThreeHooksShareOneCacheEntry() { - // WHY (PA-1): the three enumeration hooks share ONE (db,table,snapshotId) entry -- listPartitions - // populates it, listPartitionNames and listPartitionValues then derive from the SAME cached list - // without re-enumerating, and the derived outputs stay byte-consistent with listPartitions' rendered - // list. MUTATION: any hook bypassing the shared cachedPartitions -> loadCount > 1 -> red. + public void bothHooksShareOneCacheEntry() { + // WHY (PA-1): the two enumeration hooks share ONE (db,table,snapshotId) entry -- listPartitions + // populates it and listPartitionNames then derives from the SAME cached list without re-enumerating, + // with the derived names byte-consistent with listPartitions' rendered list. The partition_values() + // table function also lands on listPartitions, so it is covered by this same entry. + // MUTATION: either hook bypassing the shared cachedPartitions -> loadCount > 1 -> red. RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); FakePaimonTable table = regionTable(); ops.table = table; @@ -324,22 +302,18 @@ public void allThreeHooksShareOneCacheEntry() { List full = md.listPartitions(null, h, Optional.empty()); List namesOut = md.listPartitionNames(null, h); - List> valuesOut = md.listPartitionValues(null, h, Collections.singletonList("region")); - - Assertions.assertEquals(1, loadCount(ops), "all three hooks must share one cache entry (enumerate once)"); - Assertions.assertEquals(names(full), namesOut, "listPartitionNames equals the names of listPartitions' list"); - Assertions.assertEquals( - full.stream().map(p -> Collections.singletonList(p.getPartitionValues().get("region"))) - .collect(Collectors.toList()), - valuesOut, "listPartitionValues equals the values derived from listPartitions' list"); + + Assertions.assertEquals(1, loadCount(ops), "both hooks must share one cache entry (enumerate once)"); + Assertions.assertEquals(names(full), namesOut, + "listPartitionNames equals the names of listPartitions' list"); } @Test - public void unpartitionedNamesAndValuesBypassCacheWithoutTouchingSnapshotSeam() { - // WHY (PA-1): the "no seam call for unpartitioned" contract must hold for the new routing of - // listPartitionNames and listPartitionValues too -- cachedPartitions short-circuits before building a - // key, so neither latestSnapshotId nor listPartitions is called. MUTATION: routing through the cache - // before the emptiness check -> "latestSnapshotId" appears in ops.log -> red. + public void unpartitionedNamesBypassCacheWithoutTouchingSnapshotSeam() { + // WHY (PA-1): the "no seam call for unpartitioned" contract must hold for the cache-aware routing of + // listPartitionNames too -- cachedPartitions short-circuits before building a key, so neither + // latestSnapshotId nor listPartitions is called. MUTATION: routing through the cache before the + // emptiness check -> "latestSnapshotId" appears in ops.log -> red. RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps(); FakePaimonTable table = new FakePaimonTable( "t1", RowType.builder().field("id", DataTypes.INT()).build(), @@ -352,7 +326,6 @@ public void unpartitionedNamesAndValuesBypassCacheWithoutTouchingSnapshotSeam() PaimonConnectorMetadata md = metadataWithCache(ops, cache); Assertions.assertTrue(md.listPartitionNames(null, h).isEmpty()); - Assertions.assertTrue(md.listPartitionValues(null, h, Collections.singletonList("region")).isEmpty()); - Assertions.assertTrue(ops.log.isEmpty(), "unpartitioned names/values must not touch any remote seam"); + Assertions.assertTrue(ops.log.isEmpty(), "unpartitioned name listing must not touch any remote seam"); } } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java index d6357f58de8b41..382d8820f4b170 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPartitionValueRenderTest.java @@ -115,8 +115,8 @@ public void integerRendersViaToString() { @Test public void nullValueRendersNull() { - // WHY: every case null-guards (returns null), preserved from legacy; PaimonScanRange / - // ConnectorPartitionValues.normalize handle null entries. MUTATION: NPE or "null" string -> red. + // WHY: every case null-guards (returns null), preserved from legacy; PaimonScanRange handles the + // null entries downstream. MUTATION: NPE or "null" string -> red. Assertions.assertNull( PaimonScanPlanProvider.serializePartitionValue(DataTypes.INT(), null, "UTC")); Assertions.assertNull( diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java index 5600fd3a93e8a1..418b0113e952fa 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java @@ -20,11 +20,16 @@ import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; import org.apache.doris.connector.api.pushdown.ConnectorComparison; +import org.apache.doris.connector.api.pushdown.ConnectorLike; import org.apache.doris.connector.api.pushdown.ConnectorLiteral; +import org.apache.paimon.data.BinaryString; import org.apache.paimon.data.Timestamp; +import org.apache.paimon.predicate.Equal; +import org.apache.paimon.predicate.IsNull; import org.apache.paimon.predicate.LeafPredicate; import org.apache.paimon.predicate.Predicate; +import org.apache.paimon.predicate.StartsWith; import org.apache.paimon.types.DataTypes; import org.apache.paimon.types.RowType; import org.junit.jupiter.api.Assertions; @@ -142,4 +147,145 @@ public void intControlIsPushed() { Assertions.assertEquals(42, leaf.literals().get(0), "the INT literal must be carried through unchanged"); } + + // ---------- null-safe equality (<=>) ---------- + + @Test + public void eqForNullWithNonNullLiteralPushesEqual() { + RowType rowType = RowType.builder().field("id", DataTypes.INT()).build(); + + List predicates = convert(rowType, + ConnectorComparison.Operator.EQ_FOR_NULL, "id", ConnectorLiteral.ofInt(5)); + + // WHY: `id <=> 5` and `id = 5` select exactly the same rows (<=> yields false, never unknown, + // when id is null, and paimon's Equal likewise never matches nulls). Pushing IS NULL instead - + // which is what the port from fe-core did - is not a narrowing but an INVERSION: paimon prunes + // away every data file that holds id = 5 at planning time, and the BE-side residual filter can + // only drop rows from what was read, never bring pruned files back. The query returns 0 rows, + // with no error, no warning and a smaller partition count in EXPLAIN. + // MUTATION: restore `case EQ_FOR_NULL: return builder.isNull(idx)` -> red. + Assertions.assertEquals(1, predicates.size(), + "`id <=> 5` must be pushed as an equality predicate"); + LeafPredicate leaf = (LeafPredicate) predicates.get(0); + Assertions.assertSame(Equal.INSTANCE, leaf.function(), + "`id <=> ` must translate to Equal, never IsNull"); + Assertions.assertEquals(5, leaf.literals().get(0)); + } + + @Test + public void eqForNullWithNullLiteralPushesIsNull() { + RowType rowType = RowType.builder().field("id", DataTypes.INT()).build(); + + List predicates = convert(rowType, + ConnectorComparison.Operator.EQ_FOR_NULL, "id", ConnectorLiteral.ofNull(ANY)); + + // `id <=> NULL` is exactly IS NULL - the one case where the null-safe operator does translate. + Assertions.assertEquals(1, predicates.size()); + LeafPredicate leaf = (LeafPredicate) predicates.get(0); + Assertions.assertSame(IsNull.INSTANCE, leaf.function()); + Assertions.assertTrue(leaf.literals().isEmpty()); + } + + @Test + public void plainEqWithNullLiteralNotPushed() { + RowType rowType = RowType.builder().field("id", DataTypes.INT()).build(); + + List predicates = convert(rowType, + ConnectorComparison.Operator.EQ, "id", ConnectorLiteral.ofNull(ANY)); + + // `id = NULL` is unknown for every row. It is neither Equal nor IsNull, so the only correct + // action is to decline the pushdown. + Assertions.assertTrue(predicates.isEmpty(), + "`id = NULL` must not be pushed as anything"); + } + + @Test + public void eqForNullOnNonPushableTypeNotPushed() { + RowType rowType = RowType.builder().field("f", DataTypes.FLOAT()).build(); + + List predicates = convert(rowType, + ConnectorComparison.Operator.EQ_FOR_NULL, "f", new ConnectorLiteral(ANY, 1.5d)); + + // WHY this case is separate: FLOAT is deliberately not pushed, so the value conversion fails + // and we land in the same "no value" branch as a genuine null literal. Guarding on the operator + // ALONE would turn `f <=> 1.5` into IS NULL and recreate the very bug this batch fixes, just on + // another column type. The guard must also require that the literal really is null. + // MUTATION: relax the guard to `operator == EQ_FOR_NULL` without `literal.isNull()` -> red. + Assertions.assertTrue(predicates.isEmpty(), + "`f <=> 1.5` on a non-pushable type must be declined, not turned into IS NULL"); + } + + // ---------- LIKE ---------- + + private static List convertLike(String pattern) { + RowType rowType = RowType.builder().field("s", DataTypes.STRING()).build(); + return new PaimonPredicateConverter(rowType).convert(new ConnectorLike( + ConnectorLike.Operator.LIKE, + new ConnectorColumnRef("s", ANY), + new ConnectorLiteral(ANY, pattern))); + } + + private static void assertPrefixPushed(String pattern, String expectedPrefix) { + List predicates = convertLike(pattern); + Assertions.assertEquals(1, predicates.size(), "LIKE '" + pattern + "' must be pushed"); + LeafPredicate leaf = (LeafPredicate) predicates.get(0); + Assertions.assertSame(StartsWith.INSTANCE, leaf.function()); + Assertions.assertEquals(BinaryString.fromString(expectedPrefix), leaf.literals().get(0)); + } + + private static void assertNotPushed(String pattern) { + // WHY declining is the required answer rather than a missed optimization: this predicate drives + // paimon's partition and data-file pruning at planning time AND the BE-side JNI row filter. A + // prefix that is stricter than the user's pattern makes paimon skip files that hold matching + // rows, and nothing downstream can read them back - the query silently returns fewer rows. + Assertions.assertTrue(convertLike(pattern).isEmpty(), + "LIKE '" + pattern + "' cannot be proven equivalent to a prefix match, so it must " + + "not be pushed (declining is slow but correct; narrowing loses rows)"); + } + + @Test + public void likeTrailingWildcardPushesPrefix() { + assertPrefixPushed("abc%", "abc"); + assertPrefixPushed("abc%%", "abc"); + } + + @Test + public void likeSingleCharWildcardNotPushed() { + // '_' matches any ONE character, so 'a_c%' must also match "abc1". Treating it as a literal + // underscore prunes away every file that only holds "abc..." values. + assertNotPushed("a_c%"); + assertNotPushed("a\\_c%"); + } + + @Test + public void likeEscapedWildcardNotPushed() { + // 'a\%%' means "starts with the literal a%". The raw text carries the backslash, so pushing it + // verbatim asks paimon for values starting with "a\%" - typically matching nothing at all. + assertNotPushed("a\\%%"); + } + + @Test + public void likeInnerWildcardNotPushed() { + // 'a%b%' does not start with '%' and does end with '%', which is exactly the shape the old + // check accepted; it then pushed "a%b" as a literal prefix. + assertNotPushed("a%b%"); + } + + @Test + public void likeNonPrefixShapesStayUnpushed() { + // Regression guard on the shapes that were already declined, so the tightening did not + // accidentally start pushing them. + assertNotPushed("%abc%"); + assertNotPushed("%abc"); + assertNotPushed("abc"); + assertNotPushed("%"); + assertNotPushed("%%"); + } + + /** Builds `col literal` over a single-column RowType. */ + private static List convert(RowType rowType, ConnectorComparison.Operator op, + String colName, ConnectorLiteral literal) { + return new PaimonPredicateConverter(rowType).convert(new ConnectorComparison( + op, new ConnectorColumnRef(colName, ANY), literal)); + } } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanMetricsTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanMetricsTest.java index ec7c1e8d299ce0..03c31600f01d51 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanMetricsTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanMetricsTest.java @@ -74,9 +74,10 @@ public void prettyMsMatchesLegacyFormatter() { } @Test - public void groupNameMatchesFeCoreConstant() { - // Mirror of the fe-core SummaryProfile.PAIMON_SCAN_METRICS constant (stringly-typed coupling; the two - // modules cannot cross-import, so each asserts the shared literal). + public void groupNameIsTheUserVisibleProfileSection() { + // The group name is user-visible in the query profile, so it is pinned here. It is NOT coupled to any + // fe-core constant: the engine get-or-creates the profile child under whatever name the connector + // returns. Assertions.assertEquals("Paimon Scan Metrics", PaimonScanMetrics.GROUP_NAME); } } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderCapabilityTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderCapabilityTest.java index f58bb1c21fbc39..f5f9e916fc5560 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderCapabilityTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderCapabilityTest.java @@ -50,7 +50,7 @@ public void paimonOptsOutOfPruneToZeroShortCircuit() { @Test public void spiDefaultKeepsShortCircuit() { // A connector that does not override the capability keeps the short-circuit (MaxCompute parity). - ConnectorScanPlanProvider defaultProvider = (session, handle, columns, filter) -> Collections.emptyList(); + ConnectorScanPlanProvider defaultProvider = (session, request) -> Collections.emptyList(); Assertions.assertFalse(defaultProvider.ignorePartitionPruneShortCircuit(), "the SPI default must keep the prune-to-zero short-circuit"); } @@ -96,7 +96,7 @@ public void scannedPartitionCountEmptyForUnpartitionedTable() { @Test public void scannedPartitionCountDefaultsToEmpty() { // The SPI default (non-overriding connector, e.g. hive/MaxCompute) reports nothing. - ConnectorScanPlanProvider defaultProvider = (session, handle, columns, filter) -> Collections.emptyList(); + ConnectorScanPlanProvider defaultProvider = (session, request) -> Collections.emptyList(); Assertions.assertEquals(OptionalLong.empty(), defaultProvider.scannedPartitionCount(Collections.emptyList())); } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java index ac4f44f0c81cb1..2e4267e2296534 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java @@ -20,7 +20,9 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.filesystem.FileSystemType; import org.apache.doris.filesystem.properties.BackendStorageKind; import org.apache.doris.filesystem.properties.BackendStorageProperties; @@ -232,9 +234,9 @@ public void planScanEnumeratesSplitsInsideAuthScope(@TempDir Path warehouse) thr PaimonTableHandle handle = new PaimonTableHandle( "db", "t", Collections.emptyList(), Collections.emptyList()); - List ranges = provider.planScan( - sessionWithProps(Collections.emptyMap()), handle, - Collections.emptyList(), Optional.empty()); + List ranges = provider.planScan(sessionWithProps(Collections.emptyMap()), + ConnectorScanRequest.builder(handle, Collections.emptyList()) + .build()); Assertions.assertFalse(ranges.isEmpty(), "one committed row must plan at least one split"); Assertions.assertEquals(2, ctx.authCount, @@ -329,7 +331,7 @@ public void nativeRangeNormalizesBothDataAndDeletionVectorPaths() { // WHY: BE's scheme-dispatched S3 file factory only opens canonical s3://. An un-normalized // oss:// DATA-file path fails the native ORC/Parquet read outright; an un-normalized oss:// DV // path silently drops the deletion vector so DELETEd rows reappear (merge-on-read corruption). - // BOTH must route through ConnectorContext.normalizeStorageUri (legacy PaimonScanNode normalizes + // BOTH must route through ConnectorStorageContext.normalizeStorageUri (legacy PaimonScanNode normalizes // both via the 2-arg LocationPath.of). MUTATION: dropping normalizeUri on either site -> that // path stays oss:// -> red. Assertions.assertEquals("s3://bkt/warehouse/db/t/part-0.parquet", @@ -863,8 +865,9 @@ public void countPushdownCollapsesMultipleSplitsToOneRangeBearingSummedTotal( // MUTATION (collapse): per-split emit -> >=2 ranges carry row_count -> countRanges!=1 -> red. // MUTATION (sum): `countSum = split.mergedRowCount()` (first/last-wins instead of +=) -> "2" // or "3" instead of "5" -> red. So both halves of design D-054 are pinned. - List withCount = provider.planScan( - session, handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ true); + List withCount = provider.planScan(session, + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(true).build()); int countRanges = 0; String emittedCount = null; for (ConnectorScanRange r : withCount) { @@ -882,8 +885,9 @@ public void countPushdownCollapsesMultipleSplitsToOneRangeBearingSummedTotal( // count pushdown OFF: no range may carry a pushed-down row count (normal scan; BE counts). // MUTATION: emitting row_count regardless of the flag -> red. - List withoutCount = provider.planScan( - session, handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ false); + List withoutCount = provider.planScan(session, + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(false).build()); for (ConnectorScanRange r : withoutCount) { Assertions.assertFalse(r.getProperties().containsKey("paimon.row_count"), "without count pushdown no range may carry a pushed-down row count"); @@ -935,8 +939,9 @@ public void jniAndCountRangesCarryRealFileFormatNotJni(@TempDir Path warehouse) // (buildJniScanRange). Its file_format must be the table's "orc", not "jni". ConnectorSession forceJni = sessionWithProps( Collections.singletonMap("force_jni_scanner", "true")); - List jniRanges = provider.planScan( - forceJni, handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ false); + List jniRanges = provider.planScan(forceJni, + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(false).build()); Assertions.assertFalse(jniRanges.isEmpty(), "force_jni scan must emit >=1 JNI range"); for (ConnectorScanRange r : jniRanges) { Assertions.assertTrue(r.getProperties().containsKey("paimon.split"), @@ -949,8 +954,9 @@ public void jniAndCountRangesCarryRealFileFormatNotJni(@TempDir Path warehouse) // (b) COUNT(*) collapse range (buildCountRange): same real-format requirement; also pins that // defaultFileFormat is threaded into buildCountRange's new parameter from the call site. ConnectorSession plain = sessionWithProps(Collections.emptyMap()); - List countRanges = provider.planScan( - plain, handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ true); + List countRanges = provider.planScan(plain, + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(true).build()); PaimonScanRange countRange = null; for (ConnectorScanRange r : countRanges) { if (r.getProperties().containsKey("paimon.row_count")) { @@ -1017,8 +1023,9 @@ public void jniAndCountRangesUseFileSuffixNotAlteredTableDefault(@TempDir Path w // "orc" (the real data-file suffix), NOT "parquet" (the altered table default). ConnectorSession forceJni = sessionWithProps( Collections.singletonMap("force_jni_scanner", "true")); - List jniRanges = provider.planScan( - forceJni, handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ false); + List jniRanges = provider.planScan(forceJni, + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(false).build()); Assertions.assertFalse(jniRanges.isEmpty(), "force_jni scan must emit >=1 JNI range"); for (ConnectorScanRange r : jniRanges) { Assertions.assertTrue(r.getProperties().containsKey("paimon.split"), @@ -1030,8 +1037,9 @@ public void jniAndCountRangesUseFileSuffixNotAlteredTableDefault(@TempDir Path w // (b) COUNT(*) collapse range (buildCountRange): same suffix-over-default requirement. ConnectorSession plain = sessionWithProps(Collections.emptyMap()); - List countRanges = provider.planScan( - plain, handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ true); + List countRanges = provider.planScan(plain, + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(true).build()); PaimonScanRange countRange = null; for (ConnectorScanRange r : countRanges) { if (r.getProperties().containsKey("paimon.row_count")) { @@ -1083,15 +1091,17 @@ public void ignoreJniDropsForcedJniSplit(@TempDir Path warehouse) throws Excepti // Baseline: force_jni alone emits a JNI range. List baseline = provider.planScan( sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")), - handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ false); + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(false).build()); Assertions.assertFalse(baseline.isEmpty(), "force_jni alone must emit >=1 JNI range (baseline)"); // force_jni + IGNORE_JNI: the JNI split is dropped (2-entry props map). Map props = new HashMap<>(); props.put("force_jni_scanner", "true"); props.put("ignore_split_type", "IGNORE_JNI"); - List ignored = provider.planScan( - sessionWithProps(props), handle, noColumns, Optional.empty(), -1, null, false); + List ignored = provider.planScan(sessionWithProps(props), + ConnectorScanRequest.builder(handle, noColumns) + .build()); Assertions.assertTrue(ignored.isEmpty(), "ignore_split_type=IGNORE_JNI must drop the forced-JNI DataSplit"); } @@ -1130,9 +1140,9 @@ public void ignoreNativeDropsNativeSplit(@TempDir Path warehouse) throws Excepti List noColumns = Collections.emptyList(); // Baseline (NONE): a native range is emitted (no paimon.split marker == native path). - List baseline = provider.planScan( - sessionWithProps(Collections.emptyMap()), - handle, noColumns, Optional.empty(), -1, null, false); + List baseline = provider.planScan(sessionWithProps(Collections.emptyMap()), + ConnectorScanRequest.builder(handle, noColumns) + .build()); Assertions.assertFalse(baseline.isEmpty(), "append-only scan must emit >=1 range (baseline)"); boolean anyNative = baseline.stream() .anyMatch(r -> !r.getProperties().containsKey("paimon.split")); @@ -1142,7 +1152,8 @@ public void ignoreNativeDropsNativeSplit(@TempDir Path warehouse) throws Excepti // IGNORE_NATIVE: the native split is dropped. List ignored = provider.planScan( sessionWithProps(Collections.singletonMap("ignore_split_type", "IGNORE_NATIVE")), - handle, noColumns, Optional.empty(), -1, null, false); + ConnectorScanRequest.builder(handle, noColumns) + .build()); boolean anyNativeAfter = ignored.stream() .anyMatch(r -> !r.getProperties().containsKey("paimon.split")); Assertions.assertFalse(anyNativeAfter, @@ -1183,12 +1194,13 @@ public void ignorePaimonCppIsNoOpParity(@TempDir Path warehouse) throws Exceptio "db", "t", Collections.emptyList(), Collections.emptyList()); List noColumns = Collections.emptyList(); - int noneCount = provider.planScan( - sessionWithProps(Collections.emptyMap()), - handle, noColumns, Optional.empty(), -1, null, false).size(); + int noneCount = provider.planScan(sessionWithProps(Collections.emptyMap()), + ConnectorScanRequest.builder(handle, noColumns) + .build()).size(); int cppCount = provider.planScan( sessionWithProps(Collections.singletonMap("ignore_split_type", "IGNORE_PAIMON_CPP")), - handle, noColumns, Optional.empty(), -1, null, false).size(); + ConnectorScanRequest.builder(handle, noColumns) + .build()).size(); Assertions.assertTrue(noneCount > 0, "baseline scan must emit >=1 range"); Assertions.assertEquals(noneCount, cppCount, "IGNORE_PAIMON_CPP must be a no-op (legacy parity): same range count as NONE"); @@ -1242,10 +1254,12 @@ public void cppReaderSessionFlagNoLongerChangesThePlan(@TempDir Path warehouse) cppOff.put("force_jni_scanner", "true"); cppOff.put("enable_paimon_cpp_reader", "false"); - List onRanges = provider.planScan( - sessionWithProps(cppOn), handle, noColumns, Optional.empty(), -1, null, false); - List offRanges = provider.planScan( - sessionWithProps(cppOff), handle, noColumns, Optional.empty(), -1, null, false); + List onRanges = provider.planScan(sessionWithProps(cppOn), + ConnectorScanRequest.builder(handle, noColumns) + .build()); + List offRanges = provider.planScan(sessionWithProps(cppOff), + ConnectorScanRequest.builder(handle, noColumns) + .build()); Assertions.assertFalse(onRanges.isEmpty(), "baseline scan must emit >=1 JNI range"); Assertions.assertEquals(offRanges.size(), onRanges.size(), "the cpp flag must not change the emitted range count"); @@ -1392,8 +1406,9 @@ public void nativeFileIsSubSplitWhenFileSplitSizeForcesIt(@TempDir Path warehous // MUTATION: neuter computeFileSplitOffsets to a single whole-file range -> nativeRanges==1 -> red. ConnectorSession splitting = sessionWithProps( Collections.singletonMap("file_split_size", String.valueOf(splitSize))); - List ranges = provider.planScan( - splitting, handle, noColumns, Optional.empty(), -1, null, /*countPushdown*/ false); + List ranges = provider.planScan(splitting, + ConnectorScanRequest.builder(handle, noColumns) + .countPushdown(false).build()); List nativeRanges = new ArrayList<>(); for (ConnectorScanRange r : ranges) { if (r.getPath().isPresent()) { // native ranges carry a file path; JNI ranges do not @@ -1417,9 +1432,9 @@ public void nativeFileIsSubSplitWhenFileSplitSizeForcesIt(@TempDir Path warehous "native sub-ranges must cover exactly [0, fileLength)"); // Contrast: with the default (large) split size the small file stays a SINGLE native range. - List whole = provider.planScan( - sessionWithProps(Collections.emptyMap()), handle, noColumns, Optional.empty(), - -1, null, false); + List whole = provider.planScan(sessionWithProps(Collections.emptyMap()), + ConnectorScanRequest.builder(handle, noColumns) + .build()); long wholeNative = whole.stream().filter(r -> r.getPath().isPresent()).count(); Assertions.assertEquals(1, wholeNative, "with the default 32MB+ split size the small fixture file stays one native range"); @@ -1569,6 +1584,19 @@ public void extractVendedTokenEmptyForNullAndNonRestFileIO() { * DefaultConnectorContextVendTest; here we pin the connector wiring (static creds sourced from * toBackendProperties().toMap(), overlay order, and that the raw catalog aliases are NOT shipped). */ private static ConnectorContext scanContext(Map backendStatic, Map vended) { + ConnectorStorageContext storage = new ConnectorStorageContext() { + @Override + public List getStorageProperties() { + return backendStatic.isEmpty() + ? Collections.emptyList() + : Collections.singletonList(fakeBackendStorage(backendStatic)); + } + + @Override + public Map vendStorageCredentials(Map raw) { + return vended; + } + }; return new ConnectorContext() { @Override public String getCatalogName() { @@ -1581,15 +1609,8 @@ public long getCatalogId() { } @Override - public List getStorageProperties() { - return backendStatic.isEmpty() - ? Collections.emptyList() - : Collections.singletonList(fakeBackendStorage(backendStatic)); - } - - @Override - public Map vendStorageCredentials(Map raw) { - return vended; + public ConnectorStorageContext getStorageContext() { + return storage; } }; } @@ -1781,6 +1802,12 @@ public void getScanNodePropertiesSkipsStoragePropsWithoutBackendMappingAndMerges /** A ConnectorContext whose getStorageProperties() returns the given typed list verbatim (no vended). */ private static ConnectorContext scanContextWithStorage(List storage) { + ConnectorStorageContext storageContext = new ConnectorStorageContext() { + @Override + public List getStorageProperties() { + return storage; + } + }; return new ConnectorContext() { @Override public String getCatalogName() { @@ -1793,8 +1820,8 @@ public long getCatalogId() { } @Override - public List getStorageProperties() { - return storage; + public ConnectorStorageContext getStorageContext() { + return storageContext; } }; } @@ -2090,6 +2117,34 @@ public void schemaEvolutionRoundTripAppliesCurrentAndHistory() { .getRootField().getFields().get(0).getFieldPtr().getName()); } + @Test + public void serializedTableReachesScanLevelParams() { + // WHY: BE's paimon JNI reader aborts the scan when serialized_table is unset ("missing + // serialized_table ... possibly caused by FE/BE version mismatch"), so losing this field fails + // every paimon query outright rather than degrading it. The value used to travel through a + // generically named SPI method the engine called back into (getSerializedTable(props), which just + // read the key this provider had written itself); it is now put on the thrift here, inside the + // scan-level params hook this provider already owns. MUTATION: drop the params.setSerializedTable + // call in populateScanLevelParams -> isSetSerializedTable() false -> red. + FakePaimonTable table = new FakePaimonTable( + "t1", rowType("id"), Collections.emptyList(), Collections.emptyList()); + PaimonTableHandle handle = new PaimonTableHandle( + "db1", "t1", Collections.emptyList(), Collections.emptyList()); + handle.setPaimonTable(table); + PaimonScanPlanProvider provider = new PaimonScanPlanProvider( + new HashMap<>(), new RecordingPaimonCatalogOps(), + scanContext(Collections.emptyMap(), Collections.emptyMap())); + + Map scanProps = provider.getScanNodeProperties( + null, handle, Collections.emptyList(), Optional.empty()); + TFileScanRangeParams params = new TFileScanRangeParams(); + provider.populateScanLevelParams(params, scanProps); + + Assertions.assertTrue(params.isSetSerializedTable(), + "the serialized paimon table must reach the scan-level thrift params"); + Assertions.assertEquals(scanProps.get("paimon.serialized_table"), params.getSerializedTable()); + } + @Test public void buildSchemaInfoLowercasesTopLevelButPreservesNestedNames() { // WHY (BLOCKER): the -1/current entry is the BE table-side StructNode key; BE keys it VERBATIM and diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangePartitionNullTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangePartitionNullTest.java index f64ad21eb0fa9b..c885c524c85eca 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangePartitionNullTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangePartitionNullTest.java @@ -32,7 +32,7 @@ * P5-fix FIX-PARTITION-NULL-SENTINEL (review §5 sentinel data-edge) — pins that * {@link PaimonScanRange#populateRangeParams} derives a partition column's {@code isNull} from the * Java null ONLY (legacy {@code PaimonScanNode.setScanParams:323-326} parity), and does NOT apply - * the Hive-directory sentinel coercion of {@code ConnectorPartitionValues.normalize}. + * the Hive-directory sentinel coercion hudi applies in {@code HudiScanRange#populateRangeParams}. * *

      Paimon partition values are typed: the per-type serializer returns a Java null for a genuine * null, never a directory sentinel. So a literal {@code "\N"} or {@code "__HIVE_DEFAULT_PARTITION__"} @@ -71,8 +71,8 @@ public void onlyJavaNullIsTreatedAsNullPartition() { // WHY: paimon partition values are typed — a genuine null is a Java null, never a Hive // directory sentinel. isNull must derive from the Java null ONLY (legacy // PaimonScanNode:323-326). A literal "\N" / "__HIVE_DEFAULT_PARTITION__" is real data and - // must be kept verbatim, not coerced to NULL. MUTATION: routing through - // ConnectorPartitionValues.normalize (Hive-aware coercion) flips both literal rows to + // must be kept verbatim, not coerced to NULL. MUTATION: applying hudi's directory-name rule + // (HudiScanRange, Hive-aware coercion) flips both literal rows to // isNull=true (and the genuine null renders "\N" not "") -> red. // ordinary value -> kept, not null diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaBuilderTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaBuilderTest.java index 03b6b26480b63d..72951f93f930a1 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaBuilderTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSchemaBuilderTest.java @@ -102,8 +102,7 @@ public void identityPartitionSpecBecomesPartitionKeys() { ConnectorPartitionSpec.Style.IDENTITY, Arrays.asList( new ConnectorPartitionField("name", "identity", Collections.emptyList()), - new ConnectorPartitionField("id", "IDENTITY", Collections.emptyList())), - Collections.emptyList()); + new ConnectorPartitionField("id", "IDENTITY", Collections.emptyList()))); Schema schema = PaimonSchemaBuilder.build(baseRequest().partitionSpec(spec).build()); // WHY: identity partition fields map to partition keys by column name, in order, and the @@ -141,8 +140,7 @@ public void partitionKeysResolvedToCanonicalColumnCase() { col("Pt", ConnectorType.of("STRING"), false))); ConnectorPartitionSpec spec = new ConnectorPartitionSpec( ConnectorPartitionSpec.Style.IDENTITY, - Collections.singletonList(new ConnectorPartitionField("pt", "identity", Collections.emptyList())), - Collections.emptyList()); + Collections.singletonList(new ConnectorPartitionField("pt", "identity", Collections.emptyList()))); Schema schema = PaimonSchemaBuilder.build(req.partitionSpec(spec).build()); Assertions.assertEquals(Collections.singletonList("Pt"), schema.partitionKeys()); } @@ -160,8 +158,7 @@ public void nonIdentityPartitionTransformThrows() { ConnectorPartitionSpec spec = new ConnectorPartitionSpec( ConnectorPartitionSpec.Style.TRANSFORM, Collections.singletonList( - new ConnectorPartitionField("id", "bucket", Collections.singletonList(16))), - Collections.emptyList()); + new ConnectorPartitionField("id", "bucket", Collections.singletonList(16)))); // WHY: Paimon legacy only supported plain partition columns; a transform (bucket/year/...) // must fail-fast rather than be silently dropped (which would create a differently // partitioned table than the user asked for). MUTATION: ignoring the transform and adding diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingConnectorContext.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingConnectorContext.java index 04118b5ef8e85e..8f9eeb188814f7 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingConnectorContext.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingConnectorContext.java @@ -18,13 +18,17 @@ package org.apache.doris.connector.paimon; import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorStorageContext; +import org.apache.doris.filesystem.FileSystem; import org.apache.doris.filesystem.properties.StorageProperties; import java.util.Collections; 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) used to assert that the @@ -36,7 +40,15 @@ * proves the seam call sits INSIDE the authenticator (if the production code called the seam * directly, the recording fake would log the call despite the auth failure). */ -final class RecordingConnectorContext implements ConnectorContext { +final class RecordingConnectorContext implements ConnectorContext, ConnectorStorageContext { + + // Storage services moved onto ConnectorStorageContext; this double implements both halves and hands + // itself back, so its overrides below are the ones the connector reaches. Forgetting this getter would + // silently give the connector NOOP and make those overrides dead code. + @Override + public ConnectorStorageContext getStorageContext() { + return this; + } int authCount; boolean failAuth; @@ -61,6 +73,8 @@ public Connector createSiblingConnector(String catalogType, Map // ---- FIX-URI-NORMALIZE / FIX-REST-VENDED-URI-NORMALIZE: normalizeStorageUri hook ---- /** Number of times the connector invoked {@link #normalizeStorageUri}. */ int normalizeCount; + /** Number of times the connector asked for a batch normalizer (the once-per-scan derivation). */ + int newNormalizerCount; /** The vended token the connector passed to the most recent 2-arg {@link #normalizeStorageUri}. */ Map lastVendedToken; @@ -94,6 +108,16 @@ public String normalizeStorageUri(String rawUri, Map vendedToken return rawUri; } + @Override + public UnaryOperator newStorageUriNormalizer(Map vendedToken) { + // A DISTINGUISHABLE normalizer instance. The SPI default builds a fresh lambda that never touches + // this context, so a decorator that forgets to forward this method silently gives back the default + // one - correct results, but the once-per-scan storage-config derivation degrades to once per file + // with nothing in the logs to say so. + newNormalizerCount++; + return rawUri -> normalizeStorageUri(rawUri, vendedToken); + } + @Override public long getCatalogId() { return 0; @@ -108,4 +132,16 @@ public T executeAuthenticated(Callable task) throws Exception { } return task.call(); } + + // A distinguishable, non-null engine filesystem. The SPI default for getFileSystem is null, so a + // decorator that forgets to forward it hands the connector null instead of this instance. + final FileSystem engineFileSystem = (FileSystem) java.lang.reflect.Proxy.newProxyInstance( + RecordingConnectorContext.class.getClassLoader(), new Class[] {FileSystem.class}, + (proxy, method, args) -> null); + + @Override + public FileSystem getFileSystem(ConnectorSession session) { + return engineFileSystem; + } + } diff --git a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/TcclPinningConnectorContextTest.java b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/TcclPinningConnectorContextTest.java index 1eee7e0052ac2c..add9fb72610a4d 100644 --- a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/TcclPinningConnectorContextTest.java +++ b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/TcclPinningConnectorContextTest.java @@ -147,6 +147,27 @@ public void delegatesSiblingConnectorToTheRawContext() { "createSiblingConnector properties must reach the delegate unchanged"); } + @Test + public void delegatesEngineFileSystemAndBatchNormalizer() { + RecordingConnectorContext delegate = new RecordingConnectorContext(); + TcclPinningConnectorContext ctx = new TcclPinningConnectorContext(delegate, isolatedLoader(), () -> null); + + // These two were the actual gaps. getFileSystem fell to the SPI default and handed the connector + // null instead of the engine's per-catalog filesystem - an NPE, or (copying hive's null check) a + // message blaming the catalog's storage properties, which are fine. + // Storage now reaches the connector through the single getStorageContext() forward, so these two + // assert that ONE forward: lose it and every storage service degrades at once. + Assertions.assertSame(delegate.engineFileSystem, ctx.getStorageContext().getFileSystem(null), + "getFileSystem must reach the wrapped engine context, not the SPI default (null)"); + + // newStorageUriNormalizer fell to the SPI default too. That one stays CORRECT but silently + // undoes the optimization it exists for: the default re-derives the storage config for every URI + // instead of once per scan, and nothing logs the downgrade. + ctx.getStorageContext().newStorageUriNormalizer(Collections.emptyMap()); + Assertions.assertEquals(1, delegate.newNormalizerCount, + "newStorageUriNormalizer must reach the wrapped engine context, not the SPI default"); + } + /** Wiring-only {@link HadoopAuthenticator} double: records doAs calls and runs the action WITHOUT a UGI. */ private static final class RecordingAuthenticator implements HadoopAuthenticator { int doAsCount; diff --git a/fe/fe-connector/fe-connector-spi/pom.xml b/fe/fe-connector/fe-connector-spi/pom.xml index 1c9564ce23f6db..f52aa345b42cf8 100644 --- a/fe/fe-connector/fe-connector-spi/pom.xml +++ b/fe/fe-connector/fe-connector-spi/pom.xml @@ -34,8 +34,9 @@ under the License. Doris FE Connector SPI Service Provider Interface (SPI) for Doris FE connector abstraction. - Contains ConnectorProvider (ServiceLoader SPI entry point), ConnectorContext, - and ConnectorTypeMapper. + Contains ConnectorProvider (the ServiceLoader entry point a connector implements) + plus the engine services a connector consumes: ConnectorContext, + ConnectorMetaInvalidator and ConnectorBrokerAddress. Zero third-party external dependencies — only JDK and Doris internal SPI interfaces. diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorBrokerAddress.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorBrokerAddress.java index ee55c6cb3c3a2a..43f692a5dfd4bb 100644 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorBrokerAddress.java +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorBrokerAddress.java @@ -19,12 +19,12 @@ /** * A neutral, immutable broker backend address (host + port) returned by - * {@link ConnectorContext#getBrokerAddresses()} for a {@code FILE_BROKER} write target. + * {@link ConnectorStorageContext#getBrokerAddresses()} for a {@code FILE_BROKER} write target. * *

      This is a Thrift-free SPI carrier: the engine resolves the catalog's bound broker (a fe-core concern * the connector must not import) and hands back these neutral host/port pairs; the connector — which has * the Thrift types — maps each to its own {@code TNetworkAddress}. Exactly the same pattern as - * {@link ConnectorContext#getBackendFileType}, which returns a {@code TFileType} enum name as a String the + * {@link ConnectorStorageContext#getBackendFileType}, which returns a {@code TFileType} enum name as a String the * connector maps back, keeping this SPI free of Thrift dependencies. */ public final class ConnectorBrokerAddress { 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 c38c8a9c91044e..eb20d9a44e6d5a 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 @@ -19,19 +19,19 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorHttpSecurityHook; -import org.apache.doris.connector.api.ConnectorSession; -import org.apache.doris.filesystem.FileSystem; -import org.apache.doris.filesystem.properties.StorageProperties; import java.util.Collections; -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. * Provides access to engine-level services. + * + *

      Every service here applies to any connector, whatever it reads. The storage and backend-facing + * services — which most connectors never touch — live on {@link ConnectorStorageContext}, reached through + * {@link #getStorageContext()}. A new engine service belongs on whichever of the two its audience matches; + * putting a storage service here would put it back in front of every connector that has no storage. */ public interface ConnectorContext { @@ -65,19 +65,23 @@ default ConnectorHttpSecurityHook getHttpSecurityHook() { } /** - * Sanitizes a JDBC URL according to engine-level security policies. - * The engine may reject URLs that target internal networks, contain - * banned parameters, or otherwise violate security rules. + * Sanitizes an outbound URL according to engine-level security policies. The engine may reject URLs + * that target internal networks, contain banned parameters, or otherwise violate security rules. The + * check is protocol-neutral; a JDBC URL is simply the case that exists today. * - *

      Connectors MUST call this method before using any JDBC URL - * to establish a database connection. + *

      Scope. A connector MUST route a URL through this hook when the connector itself opens the + * connection. It cannot cover a connection opened inside a third-party SDK the connector hands + * a user-supplied address to — an Iceberg JDBC metastore or a Paimon JDBC catalog builds its own + * connection with no hook point the connector can reach — so those addresses do not pass through here. + * Read this as "the engine's check applies where a connector connects", not as "every outbound address + * in FE is checked". * - * @param jdbcUrl the raw JDBC URL + * @param url the raw outbound URL * @return the sanitized URL (may be the same string if no changes needed) * @throws RuntimeException if the URL violates security policies */ - default String sanitizeJdbcUrl(String jdbcUrl) { - return jdbcUrl; + default String sanitizeOutboundUrl(String url) { + return url; } /** @@ -99,17 +103,6 @@ default T executeAuthenticated(Callable task) throws Exception { return task.call(); } - /** - * Returns the meta invalidator the connector can call to notify the engine - * of external metadata changes (e.g. from HMS notification events). - * - *

      Connectors that have no external change notifications can ignore this; - * the default returns {@link ConnectorMetaInvalidator#NOOP}.

      - */ - default ConnectorMetaInvalidator getMetaInvalidator() { - return ConnectorMetaInvalidator.NOOP; - } - /** * Builds a sibling connector of another catalog type on top of this same catalog's context, for a * heterogeneous "gateway" connector that serves more than one table format from a single catalog and must @@ -149,267 +142,14 @@ default Connector createSiblingConnector(String catalogType, Map } /** - * Normalizes raw per-table vended cloud-storage credentials (the token map a REST catalog - * returns, e.g. {@code fs.oss.accessKeyId} / {@code s3.access-key}) into the BE-facing storage - * property map ({@code AWS_ACCESS_KEY} / {@code AWS_SECRET_KEY} / {@code AWS_TOKEN} / - * {@code AWS_ENDPOINT} / {@code AWS_REGION}). The connector extracts the raw token from the live - * table (paimon SDK only); the engine performs the same {@code StorageProperties} normalization - * it uses for static catalog credentials (the connector cannot import fe-core). - * - *

      The default returns empty (no normalization machinery / empty input), so every other - * connector is unaffected. - * - * @param rawVendedCredentials the raw per-table token map (may be null/empty) - * @return the BE-facing normalized storage-property map, or empty when none - */ - default Map vendStorageCredentials(Map rawVendedCredentials) { - return Collections.emptyMap(); - } - - /** - * Normalizes a raw storage URI a connector emits (e.g. a paimon native data-file or - * deletion-vector path such as {@code oss://…}, {@code cos://…}, {@code obs://…}, {@code s3a://…}, - * or the OSS {@code bucket.endpoint} authority form) into BE's canonical, scheme-dispatched form - * ({@code s3://…}) using the catalog's storage properties. BE's file factory only recognizes the - * canonical scheme, so a connector that hands native file paths to BE MUST route them through this - * hook; otherwise the native read fails (data file) or silently returns wrong rows (deletion - * vector / merge-on-read). The connector cannot perform this itself (it must not import fe-core's - * {@code LocationPath} / {@code StorageProperties}); the engine applies the same normalization it - * uses for static catalog paths. - * - *

      The default returns the input unchanged (no normalization machinery), so every other - * connector — and any URI already in canonical form — is unaffected. - * - * @param rawUri the raw storage URI (null/blank is returned unchanged) - * @return the normalized BE-facing URI - * @throws RuntimeException if normalization fails (fail-loud, legacy parity — a wrong path would - * otherwise silently corrupt reads rather than surface the misconfiguration) - */ - default String normalizeStorageUri(String rawUri) { - return rawUri; - } - - /** - * Vended-credential-aware variant of {@link #normalizeStorageUri(String)}. For a REST catalog the - * catalog's static storage map is empty by design (vended creds are per-table/dynamic), so - * the single-arg form would throw on an object-store path. This overload lets the connector pass the - * raw per-table vended token (the same map it gives {@link #vendStorageCredentials}); the engine - * normalizes the URI against the vended credentials when present and falls back to the static map - * otherwise (legacy {@code VendedCredentialsFactory} precedence: vended replaces static). - * - *

      The default ignores the token and delegates to {@link #normalizeStorageUri(String)}, so every - * connector that has no vended credentials — and the no-op default — is unaffected. - * - * @param rawUri the raw storage URI (null/blank is returned unchanged) - * @param rawVendedCredentials the raw per-table vended token map (may be null/empty → static path) - * @return the normalized BE-facing URI - * @throws RuntimeException if normalization fails (fail-loud, legacy parity) - */ - default String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { - 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 - * {@link #normalizeStorageUri(String, Map)}: a connector that hands an output location to a BE table - * sink must tell BE which file-system family to open it with, and that decision (object store vs HDFS - * vs local vs broker) lives in the engine's {@code LocationPath} together with the catalog's storage - * properties — which the connector must not import. The result is the enum name (a plain - * String) so this SPI stays Thrift-free, exactly like {@link #normalizeStorageUri}; the connector, - * which has the Thrift types, maps it back. The engine resolves it the same way it does for a legacy - * external-table sink. - * - *

      The default derives the type from the URI scheme alone (object-store schemes → {@code FILE_S3}, - * {@code hdfs}/{@code viewfs} → {@code FILE_HDFS}, {@code file} or no scheme → {@code FILE_LOCAL}); it - * has no storage-property machinery and so cannot detect a broker-backed path — the engine override - * does. Mirrors the vended-aware normalization: the same raw per-table vended token is accepted so a - * REST catalog (empty static map) still resolves. - * - * @param rawUri the raw storage URI - * @param rawVendedCredentials the raw per-table vended token map (may be null/empty → static path) - * @return the BE file type enum name for the URI - */ - default String getBackendFileType(String rawUri, Map rawVendedCredentials) { - if (rawUri == null) { - return "FILE_LOCAL"; - } - int schemeEnd = rawUri.indexOf("://"); - if (schemeEnd < 0) { - return "FILE_LOCAL"; - } - String scheme = rawUri.substring(0, schemeEnd).toLowerCase(); - if ("hdfs".equals(scheme) || "viewfs".equals(scheme)) { - return "FILE_HDFS"; - } - if ("file".equals(scheme)) { - return "FILE_LOCAL"; - } - return "FILE_S3"; - } - - /** - * Resolves the broker backend addresses bound to this catalog (host + port), for a write whose - * {@link #getBackendFileType} resolved to {@code FILE_BROKER} (e.g. an {@code ofs://} / {@code gfs://} - * iceberg write). A write-side companion to {@link #getBackendFileType}: a connector that hands a - * broker-backed output location to a BE table sink must also tell BE which brokers to open it through, - * and the broker registry (alive instances) + the catalog's bound broker name live in the engine, which - * the connector must not import. Returns neutral {@link ConnectorBrokerAddress} host/port pairs so this - * SPI stays Thrift-free — the connector, which has the Thrift types, maps them to {@code TNetworkAddress}, - * exactly like it maps the {@link #getBackendFileType} String back to {@code TFileType}. - * - *

      The engine override resolves the catalog's bound broker (or any alive broker when none is bound) and - * shuffles for load-balance, mirroring legacy write planning ({@code BaseExternalTableDataSink}); the - * connector applies these only for a {@code FILE_BROKER} target and fails loud when the resolved set is - * empty. The default returns empty (no broker machinery), so every non-broker write — and every other - * connector — is unaffected. - * - * @return the catalog's broker backend addresses, or empty when none - */ - default List getBrokerAddresses() { - return Collections.emptyList(); - } - - /** - * Returns the catalog's static storage credentials/config normalized to BE-canonical scan - * properties: object-store creds as {@code AWS_ACCESS_KEY} / {@code AWS_SECRET_KEY} / - * {@code AWS_TOKEN} / {@code AWS_ENDPOINT} / {@code AWS_REGION}, and HDFS config as the resolved - * {@code hadoop.*} / {@code dfs.*} keys (user overrides plus the legacy-derived defaults). The - * engine runs the same {@code CredentialUtils.getBackendPropertiesFromStorageMap} that legacy / - * iceberg / hive use over the catalog's parsed {@code StorageProperties} map — the single source of - * truth — so there is no re-ported normalization that could drift. - * - *

      BE's native (FILE_S3) reader understands ONLY these canonical keys. A connector that copies - * the raw catalog aliases ({@code s3.access_key}, {@code oss.access_key}, …) to BE hands the native - * reader no usable credentials → 403 on a private bucket. A connector that emits static storage - * props to BE MUST source them from this hook. - * - *

      The default returns empty (no normalization machinery / no storage map), so every other - * connector — and any credential-less (e.g. local-filesystem) warehouse — is unaffected. - * - * @return the BE-facing normalized storage-property map, or empty when none - */ - default Map getBackendStorageProperties() { - return Collections.emptyMap(); - } - - /** - * Asks one alive backend to reach the given storage location, so a {@code test_connection=true} - * CREATE CATALOG fails on a warehouse that FE can read but BE cannot (a different network, a - * different credential set). The FE-side probe a connector runs itself cannot catch that. - * - *

      The engine owns the round-trip (picking a live backend, the RPC, the status check) because it - * needs the backend registry and the client pool, which no plugin can see. It does not interpret the - * payload: {@code storageBackendTypeValue} is the connector's own {@code TStorageBackendType} enum - * value and {@code backendProperties} the BE-facing property map, sourced from - * {@link #getBackendStorageProperties()} / {@link #getStorageProperties()}. Callers targeting S3 must - * include a {@code test_location} entry — BE requires it. - * - *

      The default does nothing (no backend fleet, e.g. in connector unit tests), matching the legacy - * behavior of skipping the probe when no backend is alive. - * - * @param storageBackendTypeValue the {@code TStorageBackendType} value BE should probe with - * @param backendProperties BE-facing storage properties (credentials, endpoint, test_location) - * @throws Exception if the backend reports the storage unreachable - */ - default void testBackendStorageConnectivity(int storageBackendTypeValue, - Map backendProperties) throws Exception { - // Default: no backend fleet to ask -> skip. - } - - /** - * Returns the catalog's static storage configuration as a list of typed, already-bound - * {@link StorageProperties} (the fe-filesystem API contract). fe-core binds the catalog's raw - * properties against the registered filesystem providers and hands the result down here, so a - * connector can derive both its Hadoop/{@code HiveConf} config - * ({@code toHadoopProperties().toHadoopConfigurationMap()}) and its BE-facing credentials - * ({@code toBackendProperties().toMap()}) without importing fe-core or any storage provider — - * it sees only the {@code fe-filesystem-api} interface. - * - *

      One entry per configured backend (e.g. an object store, plus HDFS when present), mirroring - * the engine's parsed storage list. HDFS has a typed model and contributes its - * {@code hadoop.config.resources} XML + HA + auth keys via {@code toHadoopProperties()} (C2); - * backends with no typed model (broker/local) are absent and the connector handles those via its own - * raw {@code fs.}/{@code dfs.}/{@code hadoop.} passthrough. - * - *

      The default returns an empty list (no storage machinery), so every other connector — and any - * credential-less warehouse — is unaffected. - * - * @return the catalog's typed storage properties, or an empty list when none - */ - default List getStorageProperties() { - return Collections.emptyList(); - } - - /** - * Returns the engine's {@link FileSystem} for this catalog — a scheme-routing handle backed by the - * catalog's parsed {@link #getStorageProperties() storage properties} and the registered fe-filesystem - * providers (hdfs/s3/oss/cos/obs/azure/http/local/broker). A connector uses it to list, read, and write - * table data without bundling any Hadoop {@code FileSystem} implementation itself; the engine owns scheme - * routing and per-scheme classloader pinning, exactly as Trino's {@code TrinoFileSystemFactory.create(session)} - * hands the connector a {@code TrinoFileSystem}. - * - *

      Ownership. The returned filesystem is engine-owned and connector-borrowed: the engine - * builds and caches it per catalog and closes it when the catalog/context is torn down. A connector MUST NOT - * call {@link FileSystem#close()} on it. - * - *

      Identity. The {@code session} parameter mirrors Trino's {@code create(ConnectorSession)} shape and - * reserves per-user identity via {@link ConnectorSession#getUser()}. The current implementation resolves the - * filesystem at catalog granularity (the session is not yet used to key a per-user filesystem); when per-user - * identity lands, the engine will key the cache by identity. - * - *

      The default returns {@code null} (no engine-managed filesystem), so connectors that do not use it — and - * the no-op default context — are unaffected, matching the benign default of - * {@link #getBackendStorageProperties()}. - * - * @param session the query/connector session (reserved for per-user identity; may be null for catalog-level use) - * @return the catalog's engine-owned {@link FileSystem}, or {@code null} when the engine manages no storage - */ - default FileSystem getFileSystem(ConnectorSession session) { - return null; - } - - /** - * Best-effort removal of the EMPTY directory shells left behind after a connector drops a managed - * table or database. The data + metadata FILES are already deleted by the connector's own drop (e.g. - * iceberg {@code dropTable(purge=true)}); this only prunes now-empty directories (the parent table / - * database location, descending {@code tableChildDirs} first). A directory is removed ONLY when it - * contains no files — never recursively deleting live data. - * - *

      The connector decides WHEN to call this (e.g. iceberg only for HMS-managed locations) and captures - * {@code location} BEFORE the drop; the engine owns the {@code fe-filesystem} machinery to build a - * {@code FileSystem} from the catalog's storage properties (which the connector cannot construct). Any - * failure is swallowed (logged) — cleanup is cosmetic and must never fail the drop. - * - *

      The default is a no-op, so connectors whose engine does not manage storage cleanup are unaffected. + * This catalog's storage and backend-facing services. Never {@code null}: a catalog whose storage the + * engine does not manage answers {@link ConnectorStorageContext#NOOP}, whose every method keeps its + * interface default. * - * @param location the table/database root location to prune (no-op when blank) - * @param tableChildDirs engine-format child directories to descend first (e.g. iceberg - * {@code ["data", "metadata"]}); empty/{@code null} for a database/namespace root + *

      The returned object is stable for the life of the catalog, so a connector may take it once at + * construction and hold it. */ - default void cleanupEmptyManagedLocation(String location, List tableChildDirs) { - // no-op: the engine that manages storage overrides this. + default ConnectorStorageContext getStorageContext() { + return ConnectorStorageContext.NOOP; } } diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java deleted file mode 100644 index 3d94c3c244dc9a..00000000000000 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java +++ /dev/null @@ -1,57 +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.spi; - -import java.util.List; - -/** - * Callback the connector uses to notify the engine that external metadata - * has changed and cached entries should be dropped (e.g. when an HMS - * notification event reports a CREATE / ALTER / DROP). - * - *

      Obtained from {@link ConnectorContext#getMetaInvalidator()}.

      - * - *

      Connectors that have no external change notifications can ignore this - * interface entirely; the engine provides a {@link #NOOP} default.

      - */ -public interface ConnectorMetaInvalidator { - - ConnectorMetaInvalidator NOOP = new ConnectorMetaInvalidator() { }; - - /** Invalidates the entire catalog's metadata caches. */ - default void invalidateAll() { } - - /** Invalidates cached metadata for one database. */ - default void invalidateDatabase(String dbName) { } - - /** Invalidates cached metadata for one table. */ - default void invalidateTable(String dbName, String tableName) { } - - /** - * Invalidates cached partition info for one partition. - * - * @param partitionValues partition column values in declared order - * (e.g. {@code ["2024", "01"]} for a table - * partitioned by {@code (year, month)}) - */ - default void invalidatePartition(String dbName, String tableName, - List partitionValues) { } - - /** Invalidates cached statistics for one table (without dropping schema cache). */ - default void invalidateStatistics(String dbName, String tableName) { } -} diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java index f696827664e44d..9ad8e5d8e6cc7c 100644 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java @@ -21,7 +21,10 @@ import org.apache.doris.extension.spi.Plugin; import org.apache.doris.extension.spi.PluginFactory; +import java.util.Collections; import java.util.Map; +import java.util.Optional; +import java.util.Set; /** * SPI interface for connector provider discovery via Java ServiceLoader. @@ -40,8 +43,18 @@ public interface ConnectorProvider extends PluginFactory { /** - * Returns the connector type name (e.g., "hive", "iceberg", "es"). + * Returns the connector type name (e.g., "hms", "iceberg", "es"). * Corresponds to the {@code type} property in CREATE CATALOG. + * + *

      Contract. The name must be globally unique across all installed connectors (compared + * case-insensitively) and must not be a catalog type the engine implements itself. fe-core enforces both + * when a provider is discovered: one whose type name is already claimed, or which claims an engine + * built-in type name, is refused — on the classpath that is a build error and fails loud, in a plugin + * directory it is logged and skipped so that one bad plugin cannot stop FE from starting. + * + *

      Uniqueness is not cosmetic. It is what {@code CREATE CATALOG} routes on, and it is what makes + * source-prefixed namespaces distinct by construction (see {@code ConnectorStatementScopes} in + * fe-connector-api, which relies on this method being a connector's unique identity). */ String getType(); @@ -53,6 +66,23 @@ default boolean supports(String catalogType, Map properties) { return getType().equalsIgnoreCase(catalogType); } + /** + * Returns true if this connector may appear as a standalone catalog, i.e. whether {@link #getType()} is a + * type name a user can write in {@code CREATE CATALOG ... ("type" = ...)}. Default {@code true}. + * + *

      Return {@code false} for a connector that exists only as an embedded sibling of another one + * (built and owned by that connector through {@code ConnectorContext.createSiblingConnector}, for a table + * format that is parasitic on the other connector's metastore). Such a connector still registers normally + * and stays fully reachable for sibling lookup — the engine only declines to build a catalog around it, + * because there would be no catalog semantics on the engine side to back it. + * + *

      This is the only thing that decides whether a registered connector can be reached by + * {@code CREATE CATALOG}: the engine keeps no list of accepted catalog types. + */ + default boolean isStandaloneCatalogType() { + return true; + } + /** * Creates a Connector instance for a catalog. * Called once per catalog lifecycle. @@ -75,6 +105,78 @@ default void validateProperties(Map properties) { // no-op by default } + /** + * Whether connectors of this type expose an incremental metadata-change source through + * {@code Connector#getEventSource()}. + * + *

      The engine's event driver uses this to decide whether an uninitialized catalog is worth + * force-initializing once, so that a catalog nobody has queried on this FE still seeds its event cursor + * (each FE keeps its own cursor, and a follower normally receives no queries). It is declared here rather + * than on {@code Connector} precisely because that decision is made before the connector exists: asking + * the connector would force-initialize every idle catalog, which is what this flag exists to avoid.

      + * + *

      Must agree with whether {@code Connector#getEventSource()} returns non-null — that method stays the + * single source of truth, and the engine still skips a connector whose source turns out to be null, so a + * {@code true} here costs at most one wasted initialization. Default {@code false}: a connector without + * an event source is never force-initialized.

      + */ + default boolean providesEventSource() { + return false; + } + + /** + * The database a session should land in when it switches to a catalog of this type, or empty (the default) + * to leave the session's database alone. + * + *

      For data sources whose metadata model has no database layer, where Doris invents a single fixed + * database name. Answered from the provider because the switch may happen before anything has touched the + * connector.

      + */ + default Optional defaultDatabaseOnUse() { + return Optional.empty(); + } + + /** + * The legacy engine names this connector accepts in {@code CREATE TABLE ... ENGINE=}, or empty + * (the default) if it accepts none. + * + *

      {@code ENGINE=} predates catalogs and is optional: omitting it is always legal and lets the target + * catalog decide. The clause survives only because existing SQL writes it, so the engine remains a name + * the connector owns — the engine does not keep a table of which name belongs to which data source, and + * never puts one of these names in a message. It uses them for exactly two things: deciding whether an + * explicitly written name is accepted here, and refusing at registration time a plugin that claims a name + * another plugin already claims.

      + * + *

      Note this is NOT the name a table displays: an HMS catalog accepts {@code ENGINE=hive} while + * displaying {@code hms}. Declaring a name also says nothing about which clauses the connector supports — + * {@code PARTITION BY} and {@code DISTRIBUTED BY} are validated inside + * {@code ConnectorTableDdlOps#createTable}, by the connector that has to honor them.

      + * + *

      Answered from the provider, not the connector, because the question is asked while analyzing a + * statement, before anything should force a catalog to initialize.

      + */ + default Set acceptedCreateTableEngineNames() { + return Collections.emptySet(); + } + + /** + * The name this connector's tables display as their engine. Defaults to {@link #getType()}; override only + * to spell it differently from the catalog type. + * + *

      It is what a user reads in the {@code ENGINE} column of {@code SHOW TABLE STATUS} and + * {@code information_schema.tables}, and after {@code ENGINE=} in {@code SHOW CREATE TABLE}. Both places + * show the same string.

      + * + *

      Do not confuse it with {@link #acceptedCreateTableEngineNames()}: that one is the name a user may + * write, this one is the name Doris displays, and a connector may legitimately have both + * and have them differ — an HMS catalog accepts {@code ENGINE=hive} while displaying {@code hms}. The + * engine never routes, matches or validates anything on the displayed name; it only prints it, and it + * keeps no table of which name belongs to which data source.

      + */ + default String displayEngineName() { + return getType(); + } + /** API version for compatibility checking. Major version change = incompatible. */ default int apiVersion() { return 1; diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStorageContext.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStorageContext.java new file mode 100644 index 00000000000000..862c3d5b7204e5 --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorStorageContext.java @@ -0,0 +1,324 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.spi; + +import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.filesystem.FileSystem; +import org.apache.doris.filesystem.properties.StorageProperties; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; + +/** + * The storage and backend-facing half of a catalog's engine services: credential normalization, URI + * normalization, the engine-owned filesystem, broker addresses, backend probes, and managed-location + * cleanup. Reached through {@link ConnectorContext#getStorageContext()}. + * + *

      These live apart from {@link ConnectorContext} because most connectors have no storage at all — a + * JDBC, Elasticsearch, MaxCompute or Trino-connector implementation never calls one of these methods, and + * splitting them keeps the context a new connector author reads down to the services that apply to every + * connector. The engine implements this; connectors consume it. + * + *

      Add a new storage service here, not on {@link ConnectorContext}. That is what keeps the count + * of places to touch at two — this interface and the engine implementation. A decorator of + * {@link ConnectorContext} forwards a single {@link ConnectorContext#getStorageContext()} and is + * structurally unable to lose a storage call, however many are added. + * + *

      Classloader pinning. No method here runs plugin code — every one of them executes entirely on + * the engine side — which is why the plugin-pinning decorators do not wrap this object at all. A future + * method that CAN run plugin code breaks that assumption: it would need those decorators to override + * {@link ConnectorContext#getStorageContext()} and return a pinning wrapper of their own. There is no such + * method today, so no such wrapper exists. + */ +public interface ConnectorStorageContext { + + /** + * The context for a catalog whose storage the engine does not manage. Every method keeps its interface + * default, so a connector reaching a service that is not there gets the same benign answer it would get + * from a context that simply did not override it. + */ + ConnectorStorageContext NOOP = new ConnectorStorageContext() { + }; + + /** + * Normalizes raw per-table vended cloud-storage credentials (the token map a REST catalog + * returns, e.g. {@code fs.oss.accessKeyId} / {@code s3.access-key}) into the BE-facing storage + * property map ({@code AWS_ACCESS_KEY} / {@code AWS_SECRET_KEY} / {@code AWS_TOKEN} / + * {@code AWS_ENDPOINT} / {@code AWS_REGION}). The connector extracts the raw token from the live + * table (paimon SDK only); the engine performs the same {@code StorageProperties} normalization + * it uses for static catalog credentials (the connector cannot import fe-core). + * + *

      The default returns empty (no normalization machinery / empty input), so every other + * connector is unaffected. + * + * @param rawVendedCredentials the raw per-table token map (may be null/empty) + * @return the BE-facing normalized storage-property map, or empty when none + */ + default Map vendStorageCredentials(Map rawVendedCredentials) { + return Collections.emptyMap(); + } + + /** + * Normalizes a raw storage URI a connector emits (e.g. a paimon native data-file or + * deletion-vector path such as {@code oss://…}, {@code cos://…}, {@code obs://…}, {@code s3a://…}, + * or the OSS {@code bucket.endpoint} authority form) into BE's canonical, scheme-dispatched form + * ({@code s3://…}) using the catalog's storage properties. BE's file factory only recognizes the + * canonical scheme, so a connector that hands native file paths to BE MUST route them through this + * hook; otherwise the native read fails (data file) or silently returns wrong rows (deletion + * vector / merge-on-read). The connector cannot perform this itself (it must not import fe-core's + * {@code LocationPath} / {@code StorageProperties}); the engine applies the same normalization it + * uses for static catalog paths. + * + *

      The default returns the input unchanged (no normalization machinery), so every other + * connector — and any URI already in canonical form — is unaffected. + * + * @param rawUri the raw storage URI (null/blank is returned unchanged) + * @return the normalized BE-facing URI + * @throws RuntimeException if normalization fails (fail-loud, legacy parity — a wrong path would + * otherwise silently corrupt reads rather than surface the misconfiguration) + */ + default String normalizeStorageUri(String rawUri) { + return rawUri; + } + + /** + * Vended-credential-aware variant of {@link #normalizeStorageUri(String)}. For a REST catalog the + * catalog's static storage map is empty by design (vended creds are per-table/dynamic), so + * the single-arg form would throw on an object-store path. This overload lets the connector pass the + * raw per-table vended token (the same map it gives {@link #vendStorageCredentials}); the engine + * normalizes the URI against the vended credentials when present and falls back to the static map + * otherwise (legacy {@code VendedCredentialsFactory} precedence: vended replaces static). + * + *

      The default ignores the token and delegates to {@link #normalizeStorageUri(String)}, so every + * connector that has no vended credentials — and the no-op default — is unaffected. + * + * @param rawUri the raw storage URI (null/blank is returned unchanged) + * @param rawVendedCredentials the raw per-table vended token map (may be null/empty → static path) + * @return the normalized BE-facing URI + * @throws RuntimeException if normalization fails (fail-loud, legacy parity) + */ + default String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { + 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 + * {@link #normalizeStorageUri(String, Map)}: a connector that hands an output location to a BE table + * sink must tell BE which file-system family to open it with, and that decision (object store vs HDFS + * vs local vs broker) lives in the engine's {@code LocationPath} together with the catalog's storage + * properties — which the connector must not import. The result is the enum name (a plain + * String) so this SPI stays Thrift-free, exactly like {@link #normalizeStorageUri}; the connector, + * which has the Thrift types, maps it back. The engine resolves it the same way it does for a legacy + * external-table sink. + * + *

      The default derives the type from the URI scheme alone (object-store schemes → {@code FILE_S3}, + * {@code hdfs}/{@code viewfs} → {@code FILE_HDFS}, {@code file} or no scheme → {@code FILE_LOCAL}); it + * has no storage-property machinery and so cannot detect a broker-backed path — the engine override + * does. Mirrors the vended-aware normalization: the same raw per-table vended token is accepted so a + * REST catalog (empty static map) still resolves. + * + * @param rawUri the raw storage URI + * @param rawVendedCredentials the raw per-table vended token map (may be null/empty → static path) + * @return the BE file type enum name for the URI + */ + default String getBackendFileType(String rawUri, Map rawVendedCredentials) { + if (rawUri == null) { + return "FILE_LOCAL"; + } + int schemeEnd = rawUri.indexOf("://"); + if (schemeEnd < 0) { + return "FILE_LOCAL"; + } + String scheme = rawUri.substring(0, schemeEnd).toLowerCase(); + if ("hdfs".equals(scheme) || "viewfs".equals(scheme)) { + return "FILE_HDFS"; + } + if ("file".equals(scheme)) { + return "FILE_LOCAL"; + } + return "FILE_S3"; + } + + /** + * Resolves the broker backend addresses bound to this catalog (host + port), for a write whose + * {@link #getBackendFileType} resolved to {@code FILE_BROKER} (e.g. an {@code ofs://} / {@code gfs://} + * iceberg write). A write-side companion to {@link #getBackendFileType}: a connector that hands a + * broker-backed output location to a BE table sink must also tell BE which brokers to open it through, + * and the broker registry (alive instances) + the catalog's bound broker name live in the engine, which + * the connector must not import. Returns neutral {@link ConnectorBrokerAddress} host/port pairs so this + * SPI stays Thrift-free — the connector, which has the Thrift types, maps them to {@code TNetworkAddress}, + * exactly like it maps the {@link #getBackendFileType} String back to {@code TFileType}. + * + *

      The engine override resolves the catalog's bound broker (or any alive broker when none is bound) and + * shuffles for load-balance, mirroring legacy write planning ({@code BaseExternalTableDataSink}); the + * connector applies these only for a {@code FILE_BROKER} target and fails loud when the resolved set is + * empty. The default returns empty (no broker machinery), so every non-broker write — and every other + * connector — is unaffected. + * + * @return the catalog's broker backend addresses, or empty when none + */ + default List getBrokerAddresses() { + return Collections.emptyList(); + } + + /** + * Returns the catalog's static storage credentials/config normalized to BE-canonical scan + * properties: object-store creds as {@code AWS_ACCESS_KEY} / {@code AWS_SECRET_KEY} / + * {@code AWS_TOKEN} / {@code AWS_ENDPOINT} / {@code AWS_REGION}, and HDFS config as the resolved + * {@code hadoop.*} / {@code dfs.*} keys (user overrides plus the legacy-derived defaults). The + * engine runs the same {@code CredentialUtils.getBackendPropertiesFromStorageMap} that legacy / + * iceberg / hive use over the catalog's parsed {@code StorageProperties} map — the single source of + * truth — so there is no re-ported normalization that could drift. + * + *

      BE's native (FILE_S3) reader understands ONLY these canonical keys. A connector that copies + * the raw catalog aliases ({@code s3.access_key}, {@code oss.access_key}, …) to BE hands the native + * reader no usable credentials → 403 on a private bucket. A connector that emits static storage + * props to BE MUST source them from this hook. + * + *

      The default returns empty (no normalization machinery / no storage map), so every other + * connector — and any credential-less (e.g. local-filesystem) warehouse — is unaffected. + * + * @return the BE-facing normalized storage-property map, or empty when none + */ + default Map getBackendStorageProperties() { + return Collections.emptyMap(); + } + + /** + * Asks one alive backend to reach the given storage location, so a {@code test_connection=true} + * CREATE CATALOG fails on a warehouse that FE can read but BE cannot (a different network, a + * different credential set). The FE-side probe a connector runs itself cannot catch that. + * + *

      The engine owns the round-trip (picking a live backend, the RPC, the status check) because it + * needs the backend registry and the client pool, which no plugin can see. It does not interpret the + * payload: {@code storageBackendTypeValue} is the connector's own {@code TStorageBackendType} enum + * value and {@code backendProperties} the BE-facing property map, sourced from + * {@link #getBackendStorageProperties()} / {@link #getStorageProperties()}. Callers targeting S3 must + * include a {@code test_location} entry — BE requires it. + * + *

      The default does nothing (no backend fleet, e.g. in connector unit tests), matching the legacy + * behavior of skipping the probe when no backend is alive. + * + * @param storageBackendTypeValue the {@code TStorageBackendType} value BE should probe with + * @param backendProperties BE-facing storage properties (credentials, endpoint, test_location) + * @throws Exception if the backend reports the storage unreachable + */ + default void testBackendStorageConnectivity(int storageBackendTypeValue, + Map backendProperties) throws Exception { + // Default: no backend fleet to ask -> skip. + } + + /** + * Returns the catalog's static storage configuration as a list of typed, already-bound + * {@link StorageProperties} (the fe-filesystem API contract). fe-core binds the catalog's raw + * properties against the registered filesystem providers and hands the result down here, so a + * connector can derive both its Hadoop/{@code HiveConf} config + * ({@code toHadoopProperties().toHadoopConfigurationMap()}) and its BE-facing credentials + * ({@code toBackendProperties().toMap()}) without importing fe-core or any storage provider — + * it sees only the {@code fe-filesystem-api} interface. + * + *

      One entry per configured backend (e.g. an object store, plus HDFS when present), mirroring + * the engine's parsed storage list. HDFS has a typed model and contributes its + * {@code hadoop.config.resources} XML + HA + auth keys via {@code toHadoopProperties()} (C2); + * backends with no typed model (broker/local) are absent and the connector handles those via its own + * raw {@code fs.}/{@code dfs.}/{@code hadoop.} passthrough. + * + *

      The default returns an empty list (no storage machinery), so every other connector — and any + * credential-less warehouse — is unaffected. + * + * @return the catalog's typed storage properties, or an empty list when none + */ + default List getStorageProperties() { + return Collections.emptyList(); + } + + /** + * Returns the engine's {@link FileSystem} for this catalog — a scheme-routing handle backed by the + * catalog's parsed {@link #getStorageProperties() storage properties} and the registered fe-filesystem + * providers (hdfs/s3/oss/cos/obs/azure/http/local/broker). A connector uses it to list, read, and write + * table data without bundling any Hadoop {@code FileSystem} implementation itself; the engine owns scheme + * routing and per-scheme classloader pinning, exactly as Trino's {@code TrinoFileSystemFactory.create(session)} + * hands the connector a {@code TrinoFileSystem}. + * + *

      Ownership. The returned filesystem is engine-owned and connector-borrowed: the engine + * builds and caches it per catalog and closes it when the catalog/context is torn down. A connector MUST NOT + * call {@link FileSystem#close()} on it. + * + *

      Identity. The {@code session} parameter mirrors Trino's {@code create(ConnectorSession)} shape and + * reserves per-user identity via {@link ConnectorSession#getUser()}. The current implementation resolves the + * filesystem at catalog granularity (the session is not yet used to key a per-user filesystem); when per-user + * identity lands, the engine will key the cache by identity. + * + *

      The default returns {@code null} (no engine-managed filesystem), so connectors that do not use it — and + * the no-op default context — are unaffected, matching the benign default of + * {@link #getBackendStorageProperties()}. + * + * @param session the query/connector session (reserved for per-user identity; may be null for catalog-level use) + * @return the catalog's engine-owned {@link FileSystem}, or {@code null} when the engine manages no storage + */ + default FileSystem getFileSystem(ConnectorSession session) { + return null; + } + + /** + * Best-effort removal of the EMPTY directory shells left behind after a connector drops a managed + * table or database. The data + metadata FILES are already deleted by the connector's own drop (e.g. + * iceberg {@code dropTable(purge=true)}); this only prunes now-empty directories (the parent table / + * database location, descending {@code tableChildDirs} first). A directory is removed ONLY when it + * contains no files — never recursively deleting live data. + * + *

      The connector decides WHEN to call this (e.g. iceberg only for HMS-managed locations) and captures + * {@code location} BEFORE the drop; the engine owns the {@code fe-filesystem} machinery to build a + * {@code FileSystem} from the catalog's storage properties (which the connector cannot construct). Any + * failure is swallowed (logged) — cleanup is cosmetic and must never fail the drop. + * + *

      The default is a no-op, so connectors whose engine does not manage storage cleanup are unaffected. + * + * @param location the table/database root location to prune (no-op when blank) + * @param tableChildDirs engine-format child directories to descend first (e.g. iceberg + * {@code ["data", "metadata"]}); empty/{@code null} for a database/namespace root + */ + default void cleanupEmptyManagedLocation(String location, List tableChildDirs) { + // no-op: the engine that manages storage overrides this. + } +} diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ForwardingConnectorContext.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ForwardingConnectorContext.java new file mode 100644 index 00000000000000..a74bf71a41f22c --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ForwardingConnectorContext.java @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.spi; + +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.ConnectorHttpSecurityHook; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Callable; + +/** + * Base class for a {@link ConnectorContext} decorator: forwards every method to the wrapped context. + * A decorator that needs to do something extra on one method (pinning the thread-context classloader + * to the plugin loader, say) overrides just that method; this class guarantees the rest still reach + * the engine. + * + *

      Why a base class rather than hand-written pass-throughs. Nearly every method on + * {@link ConnectorContext} has a default implementation whose semantics are a SILENT downgrade — + * {@code getFileSystem} returns {@code null}, {@code executeAuthenticated} runs the task with no + * authentication at all, {@code newStorageUriNormalizer} drops the per-scan memoization, + * {@code getStorageProperties} returns nothing. A decorator that implements the interface directly and + * copies each method by hand therefore fails OPEN: forget one and the call quietly lands on the + * interface default instead of the engine, with no compiler complaint and, for a classloader-pinning + * decorator, no pin either. The failure surfaces far away — for {@code getFileSystem} it looks like a + * NullPointerException, or like "this catalog has no storage properties" if the caller checks for + * null, which points at catalog configuration that is in fact perfectly fine. + * + *

      When you add a method to {@link ConnectorContext}, add a forward here too. + * {@code ForwardingConnectorContextTest} enforces this. And if the new method can run plugin code, + * every pinning subclass must additionally override it and apply its pin — this class only promises + * that no call is lost, not that every call is pinned. + * + *

      Storage services need no forward of their own: {@link ConnectorContext#getStorageContext()} hands the + * connector the engine's own {@link ConnectorStorageContext}, so however many are added, none can be lost + * here. That is sound only while no storage method runs plugin code (none does today — see + * {@link ConnectorStorageContext}); one that did would need a pinning subclass to override + * {@code getStorageContext()} and return a pinning wrapper of its own. + */ +public abstract class ForwardingConnectorContext implements ConnectorContext { + + private final ConnectorContext delegate; + + protected ForwardingConnectorContext(ConnectorContext delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + /** + * The wrapped context. Subclasses use it to call through without re-entering their own decoration, + * and to hand the undecorated engine context to anything that must not inherit this decorator. + */ + protected final ConnectorContext delegate() { + return delegate; + } + + @Override + public String getCatalogName() { + return delegate.getCatalogName(); + } + + @Override + public long getCatalogId() { + return delegate.getCatalogId(); + } + + @Override + public Map getEnvironment() { + return delegate.getEnvironment(); + } + + @Override + public ConnectorHttpSecurityHook getHttpSecurityHook() { + return delegate.getHttpSecurityHook(); + } + + @Override + public String sanitizeOutboundUrl(String url) { + return delegate.sanitizeOutboundUrl(url); + } + + @Override + public T executeAuthenticated(Callable task) throws Exception { + return delegate.executeAuthenticated(task); + } + + @Override + public Connector createSiblingConnector(String catalogType, Map properties) { + return delegate.createSiblingConnector(catalogType, properties); + } + + @Override + public ConnectorStorageContext getStorageContext() { + return delegate.getStorageContext(); + } +} diff --git a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/package-info.java b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/package-info.java index 57e12ec9f939f8..3ef734e1ec3fcd 100644 --- a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/package-info.java +++ b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/package-info.java @@ -16,14 +16,36 @@ // under the License. /** - * Service Provider Interface for Doris FE connector plugins. + * The plugin entry point, plus the engine services a connector CONSUMES. * - *

      This package defines the SPI contracts that connector implementations - * must fulfill. The primary entry point is {@link ConnectorProvider}, - * discovered via Java ServiceLoader or directory-based plugin loading. + *

      Two kinds of type live here, and the difference is who implements them:

      * - *

      Connector implementations should depend on this module - * ({@code fe-connector-spi}) and register their {@link ConnectorProvider} - * in {@code META-INF/services}. + *

        + *
      • {@link ConnectorProvider} — implemented by the connector. It is the single entry point the + * engine discovers, via Java ServiceLoader or directory-based plugin loading; a connector registers it + * in {@code META-INF/services}.
      • + *
      • {@link ConnectorContext}, {@link ConnectorBrokerAddress} — implemented (or produced) by the + * engine and handed to the connector: the storage, filesystem and authentication services a plugin + * may use without depending on the engine.
      • + *
      + * + *

      This package is NOT where the interfaces a connector implements live. Apart from + * {@link ConnectorProvider}, essentially everything a connector implements — {@code Connector}, + * {@code ConnectorMetadata} and its sub-interfaces, the scan / write / procedure providers, the handle and + * value types, the capability enum — is in {@code fe-connector-api}, which this module depends on and + * therefore brings in transitively. The design rules for the whole connector surface (where to declare a + * capability, which exception to throw, where thrift is allowed, object lifetimes and threading) are written + * down once, in that module's {@code org.apache.doris.connector.api} package documentation. Read it + * first.

      + * + *

      Note that the two module names are inverted relative to common usage, where "SPI" names what a plugin + * implements and "API" names the engine services it consumes. Here {@code fe-connector-spi} is the smaller + * module and holds mostly engine-provided services. The names are deliberately not being changed; read the + * content rather than the name.

      + * + *

      Types in this package deliberately avoid thrift even for data that ends up at BE (an enum NAME as a + * {@code String}, a neutral broker-address type, a thrift enum's integer VALUE as an {@code int}). Treat that + * as a local convention of this module, not as a guarantee that the connector surface is thrift-free: this + * module depends on {@code fe-connector-api}, which does use thrift types at the BE protocol boundary.

      */ package org.apache.doris.connector.spi; diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorContextTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorContextTest.java index 4153f73267678c..a39055be2239dc 100644 --- a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorContextTest.java +++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorContextTest.java @@ -18,13 +18,11 @@ package org.apache.doris.connector.spi; import org.apache.doris.connector.api.Connector; -import org.apache.doris.filesystem.properties.StorageProperties; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.util.Collections; -import java.util.List; public class ConnectorContextTest { @@ -44,29 +42,13 @@ public long getCatalogId() { } @Test - public void getStorageProperties_defaultsToEmptyList() { - // The new storage seam (D-003): fe-core overrides this to hand the connector the catalog's - // typed fe-filesystem StorageProperties. Every OTHER connector keeps the default empty list, - // so introducing the seam must not change their behavior -- and it must never return null. - List storage = minimalContext().getStorageProperties(); - Assertions.assertNotNull(storage, "getStorageProperties() must never return null"); - Assertions.assertTrue(storage.isEmpty(), - "default getStorageProperties() must be empty so non-paimon connectors are unaffected"); - } - - @Test - public void getBackendFileType_defaultDerivesFromScheme() { - // The write-side file-type seam (T06): fe-core overrides it (LocationPath, broker-aware); the - // default has no storage machinery and derives the BE file type from the URI scheme alone, - // returning the TFileType enum NAME so the SPI stays Thrift-free (like normalizeStorageUri). - ConnectorContext ctx = minimalContext(); - Assertions.assertEquals("FILE_S3", ctx.getBackendFileType("s3://bucket/data", null)); - Assertions.assertEquals("FILE_S3", ctx.getBackendFileType("oss://bucket/data", null)); - Assertions.assertEquals("FILE_HDFS", ctx.getBackendFileType("hdfs://ns/data", null)); - Assertions.assertEquals("FILE_HDFS", ctx.getBackendFileType("viewfs://ns/data", null)); - Assertions.assertEquals("FILE_LOCAL", ctx.getBackendFileType("file:///tmp/data", null)); - Assertions.assertEquals("FILE_LOCAL", ctx.getBackendFileType("/no/scheme", null)); - Assertions.assertEquals("FILE_LOCAL", ctx.getBackendFileType(null, null)); + public void getStorageContext_defaultsToNoop() { + // Storage lives on its own context now; a connector never has to null-check the getter. A catalog + // whose engine manages no storage (and every test double that does not override this) answers NOOP, + // whose methods keep the same benign defaults these assertions used to make on ConnectorContext. + ConnectorStorageContext storage = minimalContext().getStorageContext(); + Assertions.assertSame(ConnectorStorageContext.NOOP, storage, + "default getStorageContext() must be NOOP, never null"); } @Test diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorStorageContextTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorStorageContextTest.java new file mode 100644 index 00000000000000..dd16dd86044dd1 --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorStorageContextTest.java @@ -0,0 +1,88 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.spi; + +import org.apache.doris.filesystem.properties.StorageProperties; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; + +/** + * The defaults a connector gets when the engine manages no storage for its catalog. These are the same + * assertions that used to be made on {@code ConnectorContext} before the storage services moved onto their + * own interface: moving them must not have changed a single answer, because a connector reaching a service + * that is not there has to keep getting the benign result it got before. + */ +public class ConnectorStorageContextTest { + + @Test + public void getStorageProperties_defaultsToEmptyList() { + // fe-core overrides this to hand the connector the catalog's typed fe-filesystem StorageProperties. + // Every OTHER connector keeps the default empty list -- and it must never return null. + List storage = ConnectorStorageContext.NOOP.getStorageProperties(); + Assertions.assertNotNull(storage, "getStorageProperties() must never return null"); + Assertions.assertTrue(storage.isEmpty(), + "default getStorageProperties() must be empty so connectors without storage are unaffected"); + } + + @Test + public void getBackendFileType_defaultDerivesFromScheme() { + // fe-core overrides it (LocationPath, broker-aware); the default has no storage machinery and derives + // the BE file type from the URI scheme alone, returning the TFileType enum NAME so the SPI stays + // Thrift-free (like normalizeStorageUri). + ConnectorStorageContext ctx = ConnectorStorageContext.NOOP; + Assertions.assertEquals("FILE_S3", ctx.getBackendFileType("s3://bucket/data", null)); + Assertions.assertEquals("FILE_S3", ctx.getBackendFileType("oss://bucket/data", null)); + Assertions.assertEquals("FILE_HDFS", ctx.getBackendFileType("hdfs://ns/data", null)); + Assertions.assertEquals("FILE_HDFS", ctx.getBackendFileType("viewfs://ns/data", null)); + Assertions.assertEquals("FILE_LOCAL", ctx.getBackendFileType("file:///tmp/data", null)); + Assertions.assertEquals("FILE_LOCAL", ctx.getBackendFileType("/no/scheme", null)); + Assertions.assertEquals("FILE_LOCAL", ctx.getBackendFileType(null, null)); + } + + @Test + public void remainingDefaultsAreBenign() { + // The other seven, pinned together because each one's default is what makes the storage split safe + // for a connector that has no storage: it gets "nothing", never a failure and never a null it has to + // check. MUTATION: making any of these throw or return null -> red. + ConnectorStorageContext ctx = ConnectorStorageContext.NOOP; + Assertions.assertTrue(ctx.vendStorageCredentials(null).isEmpty(), + "no vending machinery -> no vended credentials"); + Assertions.assertEquals("oss://bucket/f", ctx.normalizeStorageUri("oss://bucket/f"), + "no normalization machinery -> the URI passes through unchanged"); + Assertions.assertEquals("oss://bucket/f", ctx.normalizeStorageUri("oss://bucket/f", null), + "the vended-aware overload falls back to the single-arg form"); + UnaryOperator normalizer = ctx.newStorageUriNormalizer(null); + Assertions.assertNotNull(normalizer, "the batch normalizer must never be null"); + Assertions.assertEquals("oss://bucket/f", normalizer.apply("oss://bucket/f"), + "each application must match the per-call form"); + Assertions.assertTrue(ctx.getBrokerAddresses().isEmpty(), "no broker machinery -> no brokers"); + Assertions.assertTrue(ctx.getBackendStorageProperties().isEmpty(), + "no normalization machinery -> no BE storage properties"); + Assertions.assertDoesNotThrow( + () -> ctx.testBackendStorageConnectivity(0, Map.of()), + "no backend fleet to ask -> the probe is skipped, not failed"); + Assertions.assertNull(ctx.getFileSystem(null), "no engine-managed filesystem"); + Assertions.assertDoesNotThrow(() -> ctx.cleanupEmptyManagedLocation("s3://bucket/db/t", List.of()), + "cleanup is cosmetic and must never fail a drop"); + } +} diff --git a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ForwardingConnectorContextTest.java b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ForwardingConnectorContextTest.java new file mode 100644 index 00000000000000..f0216f1c6121a0 --- /dev/null +++ b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ForwardingConnectorContextTest.java @@ -0,0 +1,205 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.connector.spi; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +/** + * Enforces that {@link ForwardingConnectorContext} forwards EVERY {@link ConnectorContext} method. + * + *

      WHY this matters: the two classloader-pinning decorators (iceberg, paimon) exist only to keep + * reflective loads inside their plugin's classloader. They used to implement {@link ConnectorContext} + * directly and copy each method by hand, which fails open: nearly every method on that interface has a + * default implementation whose semantics are a silent downgrade — {@code getFileSystem} returns + * {@code null}, {@code executeAuthenticated} runs the task with no authentication, and + * {@code newStorageUriNormalizer} discards the per-scan memoization. Miss one and the compiler says + * nothing; the call simply stops reaching the engine, and for a pinning decorator it also stops being + * pinned. That is not hypothetical: iceberg had lost {@code getFileSystem}, paimon had lost + * {@code getFileSystem} and {@code newStorageUriNormalizer}. + * + *

      So this test does not check a list someone has to remember to update — it reflects over the + * interface and requires every method to arrive at the wrapped context, which makes adding a method to + * {@link ConnectorContext} without a matching forward a build failure. + */ +public class ForwardingConnectorContextTest { + + /** Records the exact method each call landed on, so a forward can be checked one-for-one. */ + private static final class Recorder implements InvocationHandler { + private final List calls = new ArrayList<>(); + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + calls.add(method); + return defaultValue(method.getReturnType()); + } + } + + private static Object defaultValue(Class type) { + if (type == void.class) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == long.class) { + return 0L; + } + if (type == int.class) { + return 0; + } + if (type == String.class) { + return ""; + } + if (type == Map.class) { + return Collections.emptyMap(); + } + if (type == List.class) { + return Collections.emptyList(); + } + if (type.isInterface()) { + return Proxy.newProxyInstance(ForwardingConnectorContextTest.class.getClassLoader(), + new Class[] {type}, (p, m, a) -> null); + } + return null; + } + + private static Object[] argsFor(Method method) { + Class[] types = method.getParameterTypes(); + Object[] args = new Object[types.length]; + for (int i = 0; i < types.length; i++) { + if (types[i] == Callable.class) { + args[i] = (Callable) () -> null; + } else if (types[i] == String.class) { + args[i] = "x"; + } else if (types[i] == Map.class) { + args[i] = Collections.emptyMap(); + } else if (types[i] == List.class) { + args[i] = Collections.emptyList(); + } else if (types[i] == int.class) { + args[i] = 0; + } else if (types[i] == long.class) { + args[i] = 0L; + } else if (types[i] == boolean.class) { + args[i] = false; + } else { + args[i] = null; + } + } + return args; + } + + @Test + public void everyContextMethodReachesTheWrappedContext() throws Exception { + for (Method method : ConnectorContext.class.getMethods()) { + if (Modifier.isStatic(method.getModifiers())) { + continue; + } + Recorder recorder = new Recorder(); + ConnectorContext wrapped = (ConnectorContext) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[] {ConnectorContext.class}, recorder); + ConnectorContext forwarding = new ForwardingConnectorContext(wrapped) { + }; + + method.invoke(forwarding, argsFor(method)); + + Assertions.assertEquals(1, recorder.calls.size(), + method.getName() + " did not reach the wrapped context exactly once - " + + "ForwardingConnectorContext is missing a forward for it. Add one. If the " + + "method can run plugin code, the pinning subclasses (iceberg/paimon " + + "TcclPinningConnectorContext) must also override it and apply their pin: " + + "this base class only guarantees no call is LOST, not that it is pinned."); + // Compare on name AND parameter types: several methods here are overloads of each other, and + // an interface default that quietly forwards to its sibling overload (normalizeStorageUri(String, + // Map) -> normalizeStorageUri(String)) would still leave a record and pass a name-only check, + // while in fact having dropped the arguments. + Method landed = recorder.calls.get(0); + Assertions.assertEquals(method.getName(), landed.getName()); + Assertions.assertArrayEquals(method.getParameterTypes(), landed.getParameterTypes(), + method.getName() + " reached the wrapped context as a DIFFERENT overload, so its " + + "arguments were dropped on the way"); + } + } + + @Test + public void returnValueComesFromTheWrappedContext() { + ConnectorContext wrapped = new ConnectorContext() { + @Override + public String getCatalogName() { + return "engine_catalog"; + } + + @Override + public long getCatalogId() { + return 42L; + } + }; + ConnectorContext forwarding = new ForwardingConnectorContext(wrapped) { + }; + Assertions.assertEquals("engine_catalog", forwarding.getCatalogName()); + Assertions.assertEquals(42L, forwarding.getCatalogId()); + } + + @Test + public void storageContextComesFromTheWrappedContext() { + // Storage services reach the connector through a single forward, so a decorator can no longer lose one + // of them however many are added. What it CAN still lose is this one getter -- and losing it is the + // silent-downgrade failure the base class exists to prevent: the connector would get NOOP (no + // filesystem, no credentials, no normalization) and read that as "this catalog has no storage". + // MUTATION: deleting the getStorageContext() forward from the base class -> red here and in + // everyContextMethodReachesTheWrappedContext, which names the missing method. + ConnectorStorageContext engineStorage = new ConnectorStorageContext() { + }; + ConnectorContext wrapped = new ConnectorContext() { + @Override + public String getCatalogName() { + return "engine_catalog"; + } + + @Override + public long getCatalogId() { + return 42L; + } + + @Override + public ConnectorStorageContext getStorageContext() { + return engineStorage; + } + }; + ConnectorContext forwarding = new ForwardingConnectorContext(wrapped) { + }; + Assertions.assertSame(engineStorage, forwarding.getStorageContext(), + "a decorator must hand the connector the ENGINE's storage context, not the NOOP default"); + } + + @Test + public void nullDelegateRejected() { + Assertions.assertThrows(NullPointerException.class, () -> new ForwardingConnectorContext(null) { + }); + } +} diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorDorisMetadata.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorDorisMetadata.java index d27e184d92b81a..e87be576f598e6 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorDorisMetadata.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoConnectorDorisMetadata.java @@ -251,6 +251,21 @@ public Map getColumnHandles( return result; } + /** + * The trino-connector bridge accepts CAST-bearing predicates ({@code true}, the SPI default, stated here + * rather than inherited). + * + *

      This is a conscious acceptance of the risk the SPI documents, not a claim of safety: the residual + * predicate becomes a trino {@code Constraint} and is handed to the embedded trino connector's own + * {@code applyFilter}, which may turn it into source-side filtering with that system's coercion rules. It + * stays {@code true} because the bridge cannot tell which embedded connector will do so, and dropping all + * CAST-bearing conjuncts would silently de-optimize every trino catalog.

      + */ + @Override + public boolean supportsCastPredicatePushdown(ConnectorSession session) { + return true; + } + @Override public Optional> applyFilter( ConnectorSession session, diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoPredicateConverter.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoPredicateConverter.java index 06d6db0619a503..2d490a3a32bdd6 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoPredicateConverter.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoPredicateConverter.java @@ -111,10 +111,27 @@ private TupleDomain convertAnd(ConnectorAnd and) { return result; } + /** + * Folds EVERY disjunct: {@link ConnectorOr} carries a flattened N-ary list, so reading only the + * first two arms would silently narrow {@code a=1 OR a=2 OR a=3} down to {@code a IN (1, 2)} and + * lose rows the source never returns (BE re-evaluation can only remove rows, never add them back). + * + *

      Unlike {@link #convertAnd}, a failing arm is deliberately NOT caught here: skipping an AND + * conjunct only widens the pushed predicate, while dropping an OR arm narrows it. Letting the + * failure propagate makes {@link #convert} degrade to {@code TupleDomain.all()} - no pushdown at + * all - which is the only safe outcome. OR is all-or-nothing, matching IcebergPredicateConverter. + */ private TupleDomain convertOr(ConnectorOr or) { - TupleDomain left = doConvert(or.getDisjuncts().get(0)); - TupleDomain right = doConvert(or.getDisjuncts().get(1)); - return TupleDomain.columnWiseUnion(left, right); + List> parts = Lists.newArrayList(); + for (ConnectorExpression child : or.getDisjuncts()) { + parts.add(doConvert(child)); + } + if (parts.isEmpty()) { + // Unreachable once ConnectorOr enforces two-or-more disjuncts, but columnWiseUnion(List) + // rejects an empty list, so keep the fail-safe. + return TupleDomain.all(); + } + return TupleDomain.columnWiseUnion(parts); } private TupleDomain convertComparison(ConnectorComparison cmp) { diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanPlanProvider.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanPlanProvider.java index 5c6cfbdb35fe7d..18bb4f3c3ac13b 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanPlanProvider.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanPlanProvider.java @@ -19,10 +19,10 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.handle.ConnectorColumnHandle; -import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.trinoconnector.TrinoColumnMetadata; import com.fasterxml.jackson.core.JsonProcessingException; @@ -84,22 +84,11 @@ public TrinoScanPlanProvider(TrinoDorisConnector dorisConnector) { } @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter) { - return planScan(session, handle, columns, filter, -1); - } - - @Override - public List planScan( - ConnectorSession session, - ConnectorTableHandle handle, - List columns, - Optional filter, - long limit) { - TrinoTableHandle trinoHandle = (TrinoTableHandle) handle; + public List planScan(ConnectorSession session, ConnectorScanRequest request) { + List columns = request.getColumns(); + Optional filter = request.getFilter(); + long limit = request.getLimit(); + TrinoTableHandle trinoHandle = (TrinoTableHandle) request.getTableHandle(); Connector connector = dorisConnector.getTrinoConnector(); Session trinoSession = dorisConnector.getTrinoSession(); diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanRange.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanRange.java index a52c781ec1ce36..ea9f90c34a6be9 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanRange.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanRange.java @@ -18,7 +18,6 @@ package org.apache.doris.connector.trino; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.apache.doris.thrift.TFileRangeDesc; import org.apache.doris.thrift.TTableFormatFileDesc; import org.apache.doris.thrift.TTrinoConnectorFileDesc; @@ -75,11 +74,6 @@ private TrinoScanRange(Map properties, List hosts) { this.hosts = hosts; } - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public String getTableFormatType() { return "trino_connector"; diff --git a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoTypeMapping.java b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoTypeMapping.java index bab676b7064b7b..518daf11cc5ec6 100644 --- a/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoTypeMapping.java +++ b/fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoTypeMapping.java @@ -90,26 +90,28 @@ public static ConnectorType toConnectorType(Type type) { int precision = Math.min(((TimestampWithTimeZoneType) type).getPrecision(), 6); return new ConnectorType("DATETIMEV2", precision, -1); } else if (type instanceof io.trino.spi.type.ArrayType) { - ConnectorType elementType = toConnectorType( - ((io.trino.spi.type.ArrayType) type).getElementType()); - List children = new ArrayList<>(); - children.add(elementType); - return new ConnectorType("ARRAY", -1, -1, children); + return ConnectorType.arrayOf(toConnectorType( + ((io.trino.spi.type.ArrayType) type).getElementType())); } else if (type instanceof io.trino.spi.type.MapType) { - ConnectorType keyType = toConnectorType( - ((io.trino.spi.type.MapType) type).getKeyType()); - ConnectorType valueType = toConnectorType( - ((io.trino.spi.type.MapType) type).getValueType()); - List children = new ArrayList<>(); - children.add(keyType); - children.add(valueType); - return new ConnectorType("MAP", -1, -1, children); + return ConnectorType.mapOf( + toConnectorType(((io.trino.spi.type.MapType) type).getKeyType()), + toConnectorType(((io.trino.spi.type.MapType) type).getValueType())); } else if (type instanceof RowType) { - List children = new ArrayList<>(); - for (RowType.Field field : ((RowType) type).getFields()) { - children.add(toConnectorType(field.getType())); + // Carry the field NAMES, not just the field types: Doris resolves STRUCT sub-field access by + // name, so a nameless STRUCT makes fe-core invent "col0"/"col1" and every `s.a` written against + // the real schema fails analysis with "field name a was not found". + List rowFields = ((RowType) type).getFields(); + List names = new ArrayList<>(rowFields.size()); + List fieldTypes = new ArrayList<>(rowFields.size()); + for (int i = 0; i < rowFields.size(); i++) { + RowType.Field field = rowFields.get(i); + // Trino ROW fields may be anonymous (RowType.anonymousRow). Name them BY POSITION so each + // one still gets a distinct, resolvable name; the legacy fe-core mapping named every + // anonymous field "col", which collides and is unaddressable once there is more than one. + names.add(field.getName().orElse("col" + i)); + fieldTypes.add(toConnectorType(field.getType())); } - return new ConnectorType("STRUCT", -1, -1, children); + return ConnectorType.structOf(names, fieldTypes); } else { throw new IllegalArgumentException("Cannot transform unknown Trino type: " + type); } diff --git a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoPredicateConverterTest.java b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoPredicateConverterTest.java index 772cd579f2f819..25f33b38a2b46f 100644 --- a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoPredicateConverterTest.java +++ b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoPredicateConverterTest.java @@ -223,6 +223,69 @@ public void testOrUnionsSameColumn() { Assertions.assertEquals(expect("c_int", expected), CONVERTER.convert(or)); } + @Test + public void testThreeWayOrKeepsEveryArm() { + // c_int = 1 OR c_int = 2 OR c_int = 3. + // WHY this matters: ConnectorOr carries a FLATTENED N-ary list (fe-core's converters flatten + // nested ORs before handing them over), so folding only the first two arms would push + // `c_int IN (1, 2)` to the source. The source then never returns the c_int = 3 rows, and BE + // re-evaluation cannot add rows back - the query silently loses rows. + ConnectorOr or = new ConnectorOr(Arrays.asList( + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(1)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(2)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(3)))); + Domain expected = Domain.create(ValueSet.ofRanges( + Range.equal(type("c_int"), 1L), + Range.equal(type("c_int"), 2L), + Range.equal(type("c_int"), 3L)), false); + Assertions.assertEquals(expect("c_int", expected), CONVERTER.convert(or)); + } + + @Test + public void testFourWayOrKeepsEveryArm() { + // Four arms, so the fix cannot be "read one more arm" - it has to fold the whole list. + ConnectorOr or = new ConnectorOr(Arrays.asList( + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(1)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(2)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(3)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(4)))); + Domain expected = Domain.create(ValueSet.ofRanges( + Range.equal(type("c_int"), 1L), + Range.equal(type("c_int"), 2L), + Range.equal(type("c_int"), 3L), + Range.equal(type("c_int"), 4L)), false); + Assertions.assertEquals(expect("c_int", expected), CONVERTER.convert(or)); + } + + @Test + public void testCrossColumnOrDegradesToAll() { + // c_int = 1 OR c_bigint = 2 OR c_str = 'x'. + // A column-wise union keeps a column only when EVERY arm constrains it; here no column does, + // so the correct result is "no pushdown". Locks down that we never invent a constraint that + // holds in only some arms just to have something to push. + ConnectorOr or = new ConnectorOr(Arrays.asList( + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(1)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_bigint"), + ConnectorLiteral.ofLong(2L)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_str"), + ConnectorLiteral.ofString("x")))); + Assertions.assertEquals(TupleDomain.all(), CONVERTER.convert(or)); + } + + @Test + public void testOrWithUntranslatableArmDegradesToAll() { + // c_int = 1 OR c_int = 2 OR . + // OR is all-or-nothing: swallowing the failing arm would leave `c_int IN (1, 2)`, which is + // NARROWER than the user's predicate and loses rows. Dropping the whole pushdown is the only + // safe degradation. This is the opposite policy from AND, where skipping a conjunct only + // widens what is pushed and BE re-evaluation recovers exactness. + ConnectorOr or = new ConnectorOr(Arrays.asList( + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(1)), + new ConnectorComparison(ConnectorComparison.Operator.EQ, col("c_int"), ConnectorLiteral.ofInt(2)), + col("c_bool"))); + Assertions.assertEquals(TupleDomain.all(), CONVERTER.convert(or)); + } + @Test public void testNullExpressionDegradesToAll() { // A null filter must not be pushed down: scan everything. diff --git a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTypeMappingTest.java b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTypeMappingTest.java index 303357c828f2a7..3b2de9884d63bb 100644 --- a/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTypeMappingTest.java +++ b/fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTypeMappingTest.java @@ -40,6 +40,9 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Arrays; +import java.util.Collections; + /** * Unit tests for {@link TrinoTypeMapping}: every supported Trino SPI type must map to * the Doris {@link ConnectorType} name (and precision/scale/children) that the rest of @@ -130,6 +133,34 @@ public void testStructCarriesFieldTypes() { Assertions.assertEquals(2, ct.getChildren().size()); Assertions.assertEquals("INT", ct.getChildren().get(0).getTypeName()); Assertions.assertEquals("STRING", ct.getChildren().get(1).getTypeName()); + // WHY the names matter: Doris resolves STRUCT sub-field access BY NAME. Drop them here and + // fe-core invents "col0"/"col1", so DESCRIBE shows fabricated names and every `SELECT s.a` + // written against the source schema is rejected at analysis time as "field a was not found". + // The user then has no way at all to reach the field by its real name. + Assertions.assertEquals(Arrays.asList("a", "b"), ct.getFieldNames()); + Assertions.assertEquals(ct.getChildren().size(), ct.getFieldNames().size()); + } + + @Test + public void testAnonymousStructFieldsNamedByPosition() { + // Trino ROWs can be anonymous. Each field still needs a DISTINCT resolvable name; the legacy + // fe-core mapping named them all "col", which collides as soon as there is more than one. + ConnectorType ct = TrinoTypeMapping.toConnectorType( + RowType.anonymousRow(IntegerType.INTEGER, VarcharType.VARCHAR)); + Assertions.assertEquals(Arrays.asList("col0", "col1"), ct.getFieldNames()); + } + + @Test + public void testNestedStructCarriesInnerFieldNames() { + // row(a int, b row(c int)) - the recursive path has to carry names too, not just the top level. + RowType inner = RowType.rowType(RowType.field("c", IntegerType.INTEGER)); + ConnectorType ct = TrinoTypeMapping.toConnectorType(RowType.rowType( + RowType.field("a", IntegerType.INTEGER), + RowType.field("b", inner))); + Assertions.assertEquals(Arrays.asList("a", "b"), ct.getFieldNames()); + ConnectorType nested = ct.getChildren().get(1); + Assertions.assertEquals("STRUCT", nested.getTypeName()); + Assertions.assertEquals(Collections.singletonList("c"), nested.getFieldNames()); } @Test diff --git a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java index 2d3965dd82fdfb..a0f80327be294b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java @@ -72,7 +72,6 @@ import org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnCommentOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyDistributionOp; -import org.apache.doris.nereids.trees.plans.commands.info.ModifyEngineOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyPartitionOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyTableCommentOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyTablePropertiesOp; @@ -544,29 +543,20 @@ private void processAlterExternalTableInternal(List alterOps, Table ext processRename(db, externalTable, alterOps); } else if (currentAlterOps.hasSchemaChangeOp()) { schemaChangeHandler.processExternalTable(alterOps, db, externalTable); - } else if (currentAlterOps.contains(AlterOpType.MODIFY_ENGINE)) { - ModifyEngineOp modifyEngineOp = (ModifyEngineOp) alterOps.get(0); - processModifyEngine(db, externalTable, modifyEngineOp); } } - public void processModifyEngine(Database db, Table externalTable, ModifyEngineOp op) throws DdlException { - throw new DdlException("Modify engine from MySQL to ODBC is no longer supported. " - + "ODBC tables have been deprecated. Please use JDBC Catalog instead."); - } - + /** + * {@code ALTER TABLE ... MODIFY ENGINE} was removed from the grammar together with the ODBC table type it + * existed to serve, so nothing produces this log any more. The replay arm stays because an old journal may + * still carry {@code OP_MODIFY_TABLE_ENGINE}: dropping the operation type would make such an image + * unreadable. + */ public void replayProcessModifyEngine(ModifyTableEngineOperationLog log) { // ODBC tables have been deprecated, skip replay. LOG.warn("Skip replaying ModifyEngine for table {} — ODBC tables are deprecated.", log.getTableId()); } - private void processModifyEngineInternal(Database db, Table externalTable, - Map prop, boolean isReplay) { - // ODBC tables have been deprecated. This method is preserved only for - // deserialization compatibility of the edit log. No-op. - LOG.warn("processModifyEngineInternal called for deprecated ODBC engine conversion. Ignoring."); - } - /* * There's two ways to process properties' change: * 1. processAlterOlapTable will trigger schemaChangeHandler.process diff --git a/fe/fe-core/src/main/java/org/apache/doris/alter/AlterOpType.java b/fe/fe-core/src/main/java/org/apache/doris/alter/AlterOpType.java index ce701e63f1706e..950bf7efb7e814 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/alter/AlterOpType.java +++ b/fe/fe-core/src/main/java/org/apache/doris/alter/AlterOpType.java @@ -41,7 +41,6 @@ public enum AlterOpType { MODIFY_DISTRIBUTION, MODIFY_TABLE_COMMENT, MODIFY_COLUMN_COMMENT, - MODIFY_ENGINE, ALTER_BRANCH, ALTER_TAG, // partition evolution of iceberg table diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java index 97fc32e4d3de81..7a428ed4ad231d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java @@ -93,6 +93,7 @@ import org.apache.doris.common.util.Util; import org.apache.doris.connector.ConnectorFactory; import org.apache.doris.connector.ConnectorPluginManager; +import org.apache.doris.connector.spi.ConnectorProvider; import org.apache.doris.consistency.ConsistencyChecker; import org.apache.doris.cooldown.CooldownConfHandler; import org.apache.doris.datasource.CatalogIf; @@ -6506,9 +6507,16 @@ public void changeCatalog(ConnectContext ctx, String catalogName) throws DdlExce if (StringUtils.isNotEmpty(lastDb)) { ctx.setDatabase(lastDb); } - if ("es".equalsIgnoreCase( - (String) catalogIf.getProperties().get(CatalogMgr.CATALOG_TYPE_PROP))) { - ctx.setDatabase("default_db"); + // A data source whose metadata model has no database layer can name the single database Doris + // presents for it; the connector owns that name. Asked of the provider, not the connector, because + // switching to a catalog must not force it to initialize. Applied after the remembered database on + // purpose: such a catalog has exactly one database, so there is nothing else to return to. + Map catalogProps = catalogIf.getProperties(); + String catalogType = catalogProps.get(CatalogMgr.CATALOG_TYPE_PROP); + if (catalogType != null) { + ConnectorFactory.findProvider(catalogType, catalogProps) + .flatMap(ConnectorProvider::defaultDatabaseOnUse) + .ifPresent(ctx::setDatabase); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java index a201617b4f30b5..a98aa7211bb4ff 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java @@ -138,8 +138,9 @@ default boolean tryWriteLockIfExist(long timeout, TimeUnit unit) { /** * Returns the table type name used in ENGINE= clause of SHOW CREATE TABLE. - * By default this is the same as getType().name(), but plugin-driven tables - * override this to preserve the original engine name (e.g., JDBC_EXTERNAL_TABLE). + * By default this is the same as getType().name(); a plugin-driven table overrides it + * with the engine name its connector declares, so that both places a user sees an + * engine name agree. */ default String getEngineTableTypeName() { return getType().name(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java b/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java index 7011e28df7eb08..00fb9ab07bcce5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java +++ b/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java @@ -155,8 +155,6 @@ public class SummaryProfile { public static final String RPC_WORK_TIME = "RPC Work Time"; public static final String LATENCY_FROM_BE_TO_FE = "RPC Latency From BE To FE"; public static final String SPLITS_ASSIGNMENT_WEIGHT = "Splits Assignment Weight"; - public static final String ICEBERG_SCAN_METRICS = "Iceberg Scan Metrics"; - public static final String PAIMON_SCAN_METRICS = "Paimon Scan Metrics"; public static final String WAIT_CHANGE_VISIBLE_TIME = "Wait Change Visible Time"; private boolean isWarmUp = false; @@ -215,8 +213,6 @@ public boolean isWarmup() { EXTERNAL_TABLE_GET_FILE_SCAN_TASKS_TIME, SINK_SET_PARTITION_VALUES_TIME, CREATE_SCAN_RANGE_TIME, - ICEBERG_SCAN_METRICS, - PAIMON_SCAN_METRICS, NEREIDS_DISTRIBUTE_TIME, GET_META_VERSION_TIME, GET_META_VERSION_RATE_LIMIT_WAIT_TIME, @@ -275,8 +271,6 @@ public boolean isWarmup() { .put(EXTERNAL_TABLE_GET_FILE_SCAN_TASKS_TIME, 5) .put(SINK_SET_PARTITION_VALUES_TIME, 3) .put(CREATE_SCAN_RANGE_TIME, 2) - .put(ICEBERG_SCAN_METRICS, 3) - .put(PAIMON_SCAN_METRICS, 3) .put(GET_META_VERSION_RATE_LIMIT_WAIT_TIME, 1) .put(GET_PARTITION_VERSION_TIME, 1) .put(GET_PARTITION_VERSION_COUNT, 1) diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorFactory.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorFactory.java index d2bc18ee85a0ee..b274cc42c626d8 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorFactory.java @@ -19,11 +19,13 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorProvider; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.Map; +import java.util.Optional; /** * Static factory providing access to the {@link ConnectorPluginManager}. @@ -74,6 +76,39 @@ public static Connector createConnector( return mgr.createConnector(catalogType, properties, context); } + /** + * Creates a connector to back a standalone catalog. Same as {@link #createConnector} except that a + * sibling-only connector (one declaring {@code isStandaloneCatalogType() == false}) is not eligible. + * Use this on every path that builds a catalog; use {@link #createConnector} for sibling lookup. + * + * @return a ready-to-use Connector, or {@code null} if no provider claims the type as a standalone catalog + */ + public static Connector createStandaloneCatalogConnector( + String catalogType, Map properties, ConnectorContext context) { + ConnectorPluginManager mgr = pluginManager; + if (mgr == null) { + LOG.debug("ConnectorPluginManager not initialized, returning null for type: {}", + catalogType); + return null; + } + return mgr.createStandaloneCatalogConnector(catalogType, properties, context); + } + + /** + * Finds the provider that would back a catalog of this type, without creating (and therefore without + * initializing) a connector. Empty when the plugin manager is not initialized yet or no provider matches. + * + * @see ConnectorPluginManager#findProvider + */ + public static Optional findProvider( + String catalogType, Map properties) { + ConnectorPluginManager mgr = pluginManager; + if (mgr == null) { + return Optional.empty(); + } + return mgr.findProvider(catalogType, properties); + } + /** Returns true if the plugin manager has been initialized. */ public static boolean isInitialized() { return pluginManager != null; @@ -88,6 +123,15 @@ public static java.util.List getRegisteredTypes() { return mgr.getRegisteredTypes(); } + /** Returns the registered types that can be named by {@code CREATE CATALOG}, sorted. */ + public static java.util.List getStandaloneCatalogTypes() { + ConnectorPluginManager mgr = pluginManager; + if (mgr == null) { + return java.util.Collections.emptyList(); + } + return mgr.getStandaloneCatalogTypes(); + } + /** * Validates catalog properties using the matching provider. * Does nothing if no provider matches or plugin manager is not initialized. diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java index a45e0c90051ed2..87eda2416e501a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java @@ -20,6 +20,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.datasource.CatalogFactory; import org.apache.doris.extension.loader.ClassLoadingPolicy; import org.apache.doris.extension.loader.DirectoryPluginRuntimeManager; import org.apache.doris.extension.loader.LoadFailure; @@ -33,9 +34,14 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.ServiceLoader; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; /** @@ -46,12 +52,14 @@ * 2. DirectoryPluginRuntimeManager scan (production plugin directories) * *

      The first provider that returns {@code supports(catalogType, props) == true} is used. - * Classpath providers have higher priority than directory-loaded providers. + * Classpath providers have higher priority than directory-loaded providers. A provider's + * {@code getType()} must be unique and must not name a catalog type the engine implements itself; both are + * checked when the provider is discovered (see {@link #registerDiscovered}). * *

      Unlike {@link org.apache.doris.fs.FileSystemPluginManager}, this class returns - * {@code null} from {@link #createConnector} when no provider matches, allowing - * fe-core to gracefully fall back to the existing hardcoded CatalogFactory logic - * during the migration period. + * {@code null} from {@link #createConnector} when no provider matches, leaving it to the caller to decide + * how to fail — {@code CatalogFactory} then tries the catalog types the engine implements itself, and + * a gateway asking for a sibling fails with its own connector-specific message. */ public class ConnectorPluginManager { @@ -68,7 +76,20 @@ public class ConnectorPluginManager { /** Family label in the process-wide {@link PluginRegistry}. */ private static final String PLUGIN_FAMILY = "CONNECTOR"; + /** + * Engine names {@code CREATE TABLE ... ENGINE=} resolves inside the engine itself, so no plugin may + * claim one: {@code olap} is the internal catalog's, and the other three are retired table types that + * still owe the user a specific "use X instead" message from {@code InternalCatalog}. Letting a plugin + * shadow one of these would silently redirect a statement the engine answers for. + */ + private static final Set RESERVED_CREATE_TABLE_ENGINE_NAMES = + new HashSet<>(Arrays.asList("olap", "mysql", "odbc", "broker")); + private final List providers = new CopyOnWriteArrayList<>(); + /** Lower-cased type names already claimed by a discovered provider. Guards {@code getType()} uniqueness. */ + private final Set claimedTypes = ConcurrentHashMap.newKeySet(); + /** Lower-cased create-table engine names already claimed. Same uniqueness rule as {@link #claimedTypes}. */ + private final Set claimedEngineNames = ConcurrentHashMap.newKeySet(); private final DirectoryPluginRuntimeManager runtimeManager = new DirectoryPluginRuntimeManager<>(); private final ClassLoadingPolicy classLoadingPolicy = @@ -83,15 +104,93 @@ public void loadBuiltins() { // so one throwing implementation is rejected cleanly instead of // aborting startup or being active without an inventory row. PluginRegistry.getInstance().registerBuiltin(PLUGIN_FAMILY, p); - providers.add(p); - LOG.info("Registered built-in connector provider: {}", p.getType()); } catch (RuntimeException e) { LOG.warn("Skip built-in connector provider {}: self-reported metadata failed", p.getClass().getName(), e); + return; + } + // Deliberately outside that catch: registerDiscovered's fail-loud + // IllegalStateException reports a build error, not a "skip this one" condition. + if (registerDiscovered(p, true)) { + LOG.info("Registered built-in connector provider: {}", p.getType()); } }); } + /** + * Admits a discovered provider after checking its {@code getType()} contract: non-blank, not an engine + * built-in catalog type name, and not already claimed (compared case-insensitively, because + * {@link ConnectorProvider#supports} matches case-insensitively and catalog types are lower-cased before + * routing). Refusing a reserved name here is what makes it impossible for a plugin to shadow a catalog type + * the engine implements itself — routing order then cannot matter. The engine names it claims for + * {@code CREATE TABLE ... ENGINE=} go through the same two checks. + * + * @param failFast {@code true} for the classpath batch: two providers claiming one type name there is a + * build error and must fail loud. {@code false} for the plugin-directory batch: that is a + * deployment accident, so the offender is skipped and logged, preserving + * {@link #loadPlugins}'s partial-success contract (one bad plugin dir must not stop FE). + * @return true if the provider was admitted + */ + boolean registerDiscovered(ConnectorProvider provider, boolean failFast) { + String type = provider.getType(); + Set engineNames = provider.acceptedCreateTableEngineNames(); + String problem = typeNameProblem(type); + if (problem == null) { + problem = createTableEngineNameProblem(engineNames); + } + // Claim last, and only once nothing else can reject: a failed claim must not leave a name taken. + if (problem == null && !claimedTypes.add(type.toLowerCase())) { + problem = "type name '" + type + "' is already claimed by another registered connector provider"; + } + if (problem != null) { + String message = "Rejected connector provider " + provider.getClass().getName() + ": " + problem; + if (failFast) { + throw new IllegalStateException(message); + } + LOG.error("{}. The connector will not be available.", message); + return false; + } + for (String engineName : engineNames) { + claimedEngineNames.add(engineName.toLowerCase()); + } + providers.add(provider); + return true; + } + + private static String typeNameProblem(String type) { + if (type == null || type.trim().isEmpty()) { + return "getType() returned a blank type name"; + } + if (CatalogFactory.isBuiltinCatalogType(type)) { + return "type name '" + type + "' is reserved for a catalog type the engine implements itself"; + } + return null; + } + + /** + * Same uniqueness rule as the type name, applied to the engine names a provider claims for + * {@code CREATE TABLE ... ENGINE=}. Two plugins answering to one engine name would make the statement + * mean whichever registered first, so the second is refused at registration and routing order cannot + * matter — mirroring how a duplicate catalog type is handled. + */ + private String createTableEngineNameProblem(Set engineNames) { + for (String engineName : engineNames) { + if (engineName == null || engineName.trim().isEmpty()) { + return "acceptedCreateTableEngineNames() returned a blank engine name"; + } + String lower = engineName.toLowerCase(); + if (RESERVED_CREATE_TABLE_ENGINE_NAMES.contains(lower)) { + return "create-table engine name '" + engineName + + "' is reserved for an engine name the engine resolves itself"; + } + if (claimedEngineNames.contains(lower)) { + return "create-table engine name '" + engineName + + "' is already claimed by another registered connector provider"; + } + } + return null; + } + /** * Loads connector provider plugins from plugin root directories. * Failures are logged as warnings; partial success is allowed. @@ -125,11 +224,20 @@ public void loadPlugins(List pluginRoots) { runtimeManager.discard(handle.getPluginName()); continue; } - providers.add(handle.getFactory()); - PluginRegistry.getInstance().registerExternal(PLUGIN_FAMILY, handle); - LOG.info("Loaded connector plugin: name={}, pluginDir={}, jarCount={}", - handle.getPluginName(), handle.getPluginDir(), - handle.getResolvedJars().size()); + // The inventory row is written only for a provider that was actually admitted, so + // information_schema.extensions never lists a connector the routing table cannot reach. + if (registerDiscovered(handle.getFactory(), false)) { + PluginRegistry.getInstance().registerExternal(PLUGIN_FAMILY, handle); + LOG.info("Loaded connector plugin: name={}, pluginDir={}, jarCount={}", + handle.getPluginName(), handle.getPluginDir(), + handle.getResolvedJars().size()); + } else { + // registerDiscovered already logged why it refused. Release the runtime here too, so + // every "loaded from a directory but not admitted" exit discards the plugin's + // classloader — the same pairing the name-conflict path above and both of + // FileSystemPluginManager's reject paths follow. + runtimeManager.discard(handle.getPluginName()); + } } } @@ -143,21 +251,49 @@ private boolean hasProviderNamed(String name) { } /** - * Creates a Connector for the given catalog type by selecting the first supporting provider. + * Creates a Connector for the given catalog type by selecting the first supporting provider, with no + * regard for whether that connector may stand on its own as a catalog. * - *

      Returns {@code null} if no provider supports the given catalog type. - * This allows fe-core to gracefully fall back to the existing hardcoded CatalogFactory - * switch-case during the migration period. + *

      This is the sibling-lookup entry point ({@code ConnectorContext.createSiblingConnector}): a + * sibling-only connector — one whose table format is parasitic on another connector's metastore — is + * reachable only here, so this method must never filter on + * {@link ConnectorProvider#isStandaloneCatalogType()}. Use + * {@link #createStandaloneCatalogConnector} to build a catalog. * - * @param catalogType the catalog type (e.g. "hive", "iceberg", "es") + *

      Returns {@code null} if no provider supports the given catalog type; the caller decides how to fail. + * + * @param catalogType the catalog type (e.g. "hms", "iceberg", "es") * @param properties catalog configuration properties * @param context runtime context provided by fe-core * @return a ready-to-use Connector, or {@code null} if no provider matches */ public Connector createConnector( String catalogType, Map properties, ConnectorContext context) { + return createConnector(catalogType, properties, context, false); + } + + /** + * Creates a Connector to back a standalone catalog, i.e. one named by the {@code type} property of a + * {@code CREATE CATALOG}. Same selection as {@link #createConnector} except that a provider declaring + * {@link ConnectorProvider#isStandaloneCatalogType()} {@code == false} is passed over, because building a + * catalog around it would produce a catalog with no engine-side semantics behind it. + * + * @return a ready-to-use Connector, or {@code null} if no provider claims the type as a standalone catalog + */ + public Connector createStandaloneCatalogConnector( + String catalogType, Map properties, ConnectorContext context) { + return createConnector(catalogType, properties, context, true); + } + + private Connector createConnector(String catalogType, Map properties, + ConnectorContext context, boolean standaloneOnly) { for (ConnectorProvider provider : providers) { if (provider.supports(catalogType, properties)) { + if (standaloneOnly && !provider.isStandaloneCatalogType()) { + LOG.info("Provider '{}' claims catalogType='{}' but is not a standalone catalog type; " + + "it can only be built as an embedded sibling.", provider.getType(), catalogType); + continue; + } int providerVersion = provider.apiVersion(); if (providerVersion != CURRENT_API_VERSION) { LOG.warn("Skipping connector provider '{}': apiVersion={} (expected {})", @@ -169,11 +305,31 @@ public Connector createConnector( return provider.create(properties, context); } } - LOG.debug("No ConnectorProvider supports catalogType='{}'. Registered: {}", - catalogType, providerNames()); + LOG.debug("No ConnectorProvider supports catalogType='{}' (standaloneOnly={}). Registered: {}", + catalogType, standaloneOnly, providerNames()); return null; } + /** + * Finds the provider that would back a catalog of this type, without creating a connector. For engine + * decisions that must be answered for a catalog that may not be initialized yet — asking the connector + * would force-initialize it. Same selection as {@link #createConnector}: first provider that supports the + * type with a compatible API version. + * + * @return the matching provider, or empty if none matches + */ + public Optional findProvider(String catalogType, Map properties) { + for (ConnectorProvider provider : providers) { + if (provider.supports(catalogType, properties)) { + if (provider.apiVersion() != CURRENT_API_VERSION) { + continue; + } + return Optional.of(provider); + } + } + return Optional.empty(); + } + /** Returns the type names of all registered providers. */ public List getRegisteredTypes() { List types = new ArrayList<>(); @@ -183,6 +339,21 @@ public List getRegisteredTypes() { return types; } + /** + * Returns the type names that can be written in {@code CREATE CATALOG}, sorted. Excludes sibling-only + * connectors: naming one of those in a diagnostic would point the user at a type they cannot create. + */ + public List getStandaloneCatalogTypes() { + List types = new ArrayList<>(); + for (ConnectorProvider p : providers) { + if (p.isStandaloneCatalogType()) { + types.add(p.getType()); + } + } + Collections.sort(types); + return types; + } + /** * Validates catalog properties using the matching provider. * Does nothing if no provider matches. @@ -204,7 +375,12 @@ public void validateProperties(String catalogType, Map propertie } } - /** Registers a provider at highest priority (index 0). For testing overrides. */ + /** + * Registers a provider at highest priority (index 0). For testing overrides. + * + *

      Deliberately bypasses {@link #registerDiscovered}'s uniqueness check: shadowing an already-registered + * type is exactly what this method exists for (several tests stand in for a real plugin this way). + */ public void registerProvider(ConnectorProvider provider) { providers.add(0, provider); } 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 522e3782517787..a0a773cbdedf26 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 @@ -30,7 +30,7 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.spi.ConnectorBrokerAddress; import org.apache.doris.connector.spi.ConnectorContext; -import org.apache.doris.connector.spi.ConnectorMetaInvalidator; +import org.apache.doris.connector.spi.ConnectorStorageContext; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.credentials.CredentialUtils; @@ -74,8 +74,14 @@ * *

      Provides the minimal catalog-level context that connector providers need * during creation. Additional context fields can be added here as the SPI evolves. + * + *

      It implements {@link ConnectorStorageContext} as well and hands itself back from + * {@link #getStorageContext()}. The split exists so a connector reads only the services that apply to it; + * on the engine side the two halves share the catalog's parsed storage properties, the cached filesystem + * and its {@link #close()} lifecycle, so separating them into two objects would buy nothing and move code + * that has a lock and a shutdown flag in it. */ -public class DefaultConnectorContext implements ConnectorContext, Closeable { +public class DefaultConnectorContext implements ConnectorContext, ConnectorStorageContext, Closeable { private static final Logger LOG = LogManager.getLogger(DefaultConnectorContext.class); @@ -97,7 +103,7 @@ public class DefaultConnectorContext implements ConnectorContext, Closeable { // Engine-owned, per-catalog Doris FileSystem (a scheme-routing SpiSwitchingFileSystem over the catalog's // storage properties), lazily built on the first getFileSystem() and closed on catalog teardown (close()). - // Connectors BORROW it and must not close it (see ConnectorContext.getFileSystem javadoc); siblings built + // Connectors BORROW it and must not close it (see ConnectorStorageContext.getFileSystem javadoc); siblings built // via createSiblingConnector share this same context, so there is exactly one cached FS per catalog. Guarded // by fsLock; the field is dropped to null on close so a post-teardown getFileSystem() returns null. private final Object fsLock = new Object(); @@ -165,11 +171,6 @@ public ConnectorHttpSecurityHook getHttpSecurityHook() { return httpSecurityHook; } - @Override - public ConnectorMetaInvalidator getMetaInvalidator() { - return new ExternalMetaCacheInvalidator(catalogId); - } - @Override public Connector createSiblingConnector(String catalogType, Map properties) { // Build the sibling through the SAME factory the engine uses for a top-level catalog, so the sibling's @@ -183,14 +184,19 @@ public Connector createSiblingConnector(String catalogType, Map } @Override - public String sanitizeJdbcUrl(String jdbcUrl) { + public String sanitizeOutboundUrl(String url) { try { - return SecurityChecker.getInstance().getSafeJdbcUrl(jdbcUrl); + return SecurityChecker.getInstance().getSafeJdbcUrl(url); } catch (Exception e) { throw new RuntimeException("JDBC URL security check failed: " + e.getMessage(), e); } } + @Override + public ConnectorStorageContext getStorageContext() { + return this; + } + @Override public T executeAuthenticated(Callable task) throws Exception { return authSupplier.get().execute(task); diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ExternalMetaCacheInvalidator.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ExternalMetaCacheInvalidator.java deleted file mode 100644 index 38fc3239d92ba2..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ExternalMetaCacheInvalidator.java +++ /dev/null @@ -1,82 +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; - -import org.apache.doris.catalog.Env; -import org.apache.doris.connector.spi.ConnectorMetaInvalidator; -import org.apache.doris.datasource.ExternalMetaCacheMgr; - -import java.util.List; -import java.util.Objects; - -/** - * fe-core side bridge from the connector SPI {@link ConnectorMetaInvalidator} to the - * engine's {@link ExternalMetaCacheMgr}. Returned by - * {@link DefaultConnectorContext#getMetaInvalidator()} so connectors that receive - * external change notifications (e.g. HMS notification events) can drop the right - * cache entries without depending on fe-core internals directly. - */ -public final class ExternalMetaCacheInvalidator implements ConnectorMetaInvalidator { - - private final long catalogId; - - public ExternalMetaCacheInvalidator(long catalogId) { - this.catalogId = catalogId; - } - - @Override - public void invalidateAll() { - mgr().invalidateCatalog(catalogId); - } - - @Override - public void invalidateDatabase(String dbName) { - mgr().invalidateDb(catalogId, Objects.requireNonNull(dbName, "dbName")); - } - - @Override - public void invalidateTable(String dbName, String tableName) { - mgr().invalidateTable(catalogId, - Objects.requireNonNull(dbName, "dbName"), - Objects.requireNonNull(tableName, "tableName")); - } - - @Override - public void invalidatePartition(String dbName, String tableName, List partitionValues) { - // The SPI carries partition column VALUES (e.g. ["2024", "01"]) but the engine's - // partition cache is keyed by partition NAMES (e.g. "year=2024/month=01"). - // Reconstructing the name requires partition column names which are not carried by - // the SPI today. Until the SPI grows that metadata, fall back to table-level - // invalidation — correct but over-broad. - mgr().invalidateTable(catalogId, - Objects.requireNonNull(dbName, "dbName"), - Objects.requireNonNull(tableName, "tableName")); - } - - @Override - public void invalidateStatistics(String dbName, String tableName) { - // ExternalMetaCacheMgr exposes no per-table statistics-only invalidation today - // (the row count cache is keyed by id, not name). Calling invalidateTable here - // would violate the SPI contract ("without dropping schema cache"), so leave as - // a no-op until a stats-only entry point exists. - } - - private static ExternalMetaCacheMgr mgr() { - return Env.getCurrentEnv().getExtMetaCacheMgr(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java b/fe/fe-core/src/main/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java index 1d06a53018abd2..f2985f470f9475 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java @@ -72,7 +72,6 @@ public static ConnectorCreateTableRequest convert(CreateTableInfo info, .comment(info.getComment()) .properties(info.getProperties()) .ifNotExists(info.isIfNotExists()) - .external(info.isExternal()) .build(); } @@ -139,14 +138,14 @@ private static ConnectorPartitionSpec convertPartition( // PartitionDefinition is a sealed family (InPartition/LessThanPartition/ // FixedRangePartition/StepPartition) carrying nereids Expressions that // require full analysis to flatten into List>. Connectors - // that need the initial values today read the Doris PartitionDesc - // directly; this converter passes an empty list and leaves richer - // lowering for a follow-up. The presence flag is still threaded so a - // connector that rejects explicit partition values (Hive external tables - // discover partitions from the data layout) can fail loud (legacy parity). + // that need the values themselves read the Doris PartitionDesc directly, + // so nothing but their PRESENCE crosses the SPI boundary. That flag is + // still threaded so a connector that rejects explicit partition values + // (Hive external tables discover partitions from the data layout) can + // fail loud (legacy parity). boolean hasExplicitValues = info.getPartitionDefs() != null && !info.getPartitionDefs().isEmpty(); - return new ConnectorPartitionSpec(style, fields, Collections.emptyList(), hasExplicitValues); + return new ConnectorPartitionSpec(style, fields, hasExplicitValues); } private static boolean hasAnyTransform(List exprs) { @@ -226,8 +225,10 @@ private static int readBucketNum(DistributionDescriptor d) { /** * Carries the {@code ORDER BY (...)} write-order clause neutrally so a connector (iceberg) can build an - * engine sort order. Iceberg-specific validation (column existence, no metric-only types, no duplicates) - * already ran in fe-core {@code CreateTableInfo} before this conversion; here we only map the shape. + * engine sort order. fe-core only gates the clause generically -- a target that does not declare + * {@code SUPPORTS_SORT_ORDER} is rejected in {@code CreateTableInfo} -- while the sort columns themselves + * are validated by the connector ({@code IcebergConnectorMetadata.validateSortOrder}). Here we only map + * the shape. */ private static List convertSortOrder(List sortFields) { if (sortFields == null || sortFields.isEmpty()) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java index 2016402685470e..4b5c748b2ee6d7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java @@ -44,17 +44,19 @@ public class CatalogFactory { private static final Logger LOG = LogManager.getLogger(CatalogFactory.class); - // Only these catalog types are routed through the SPI connector path; every other type falls through to the - // built-in ExternalCatalog switch below. "hms" is now SPI-ready: an hms catalog is a PluginDrivenExternalCatalog - // driven by the hive connector, which flips plain-hive + iceberg-on-HMS + hudi-on-HMS to the SPI path (the - // latter two served as embedded siblings of the hms gateway). - // Do NOT add "hudi": there is no standalone hudi catalog type and no HudiExternalCatalog — a hudi table is - // parasitic on an HMS catalog (HMSExternalTable, dlaType==HUDI) and is served post-cutover as an embedded - // SIBLING of the hms gateway via ConnectorContext.createSiblingConnector("hudi", ...), which bypasses this - // set. Adding "hudi" here would build a standalone PluginDrivenExternalCatalog around HudiConnector with no - // fe-core catalog class backing it. - private static final Set SPI_READY_TYPES = - ImmutableSet.of("jdbc", "es", "trino-connector", "max_compute", "paimon", "iceberg", "hms"); + // The catalog types the engine implements itself, i.e. the ones served by the switch in createCatalog below + // rather than by a connector plugin. These names are RESERVED: ConnectorPluginManager refuses a provider + // that claims one of them, so a plugin can never shadow an engine built-in and the order in which the two + // are consulted cannot change behaviour. + // Keep in sync with that switch. A name listed here with no case there would be reported as "no connector + // plugin claimed", which CatalogFactoryPluginRoutingTest catches. + // Package-visible so CatalogFactoryPluginRoutingTest can assert each name is really served by that switch. + static final Set BUILTIN_CATALOG_TYPES = ImmutableSet.of("lakesoul", "doris", "test"); + + /** Returns true if the engine implements this catalog type itself, i.e. no connector plugin may claim it. */ + public static boolean isBuiltinCatalogType(String catalogType) { + return catalogType != null && BUILTIN_CATALOG_TYPES.contains(catalogType.toLowerCase()); + } /** * create the catalog instance from catalog log. @@ -104,42 +106,22 @@ private static CatalogIf createCatalog(long catalogId, String name, String resou // after FE restart would lose the type and initLocalObjectsImpl() would fail. props.putIfAbsent(CatalogMgr.CATALOG_TYPE_PROP, catalogType); - // Try SPI connector plugin path first, but only for whitelisted types. - // Returns null if no ConnectorProvider matches the catalog type. - Connector spiConnector = null; - if (SPI_READY_TYPES.contains(catalogType)) { - spiConnector = ConnectorFactory.createConnector( - catalogType, props, new DefaultConnectorContext(name, catalogId)); - } + // Ask the connector plugins first. Any registered provider that claims this type and can stand on its + // own as a catalog wins; the engine keeps no list of accepted types, so installing a plugin is all it + // takes to make its type usable here. Returns null when nothing claims it — including for a + // sibling-only connector, whose type must never become a catalog (see ConnectorProvider + // .isStandaloneCatalogType). + Connector spiConnector = ConnectorFactory.createStandaloneCatalogConnector( + catalogType, props, new DefaultConnectorContext(name, catalogId)); if (spiConnector != null) { LOG.info("Created plugin-driven catalog '{}' via SPI connector for type '{}'", name, catalogType); catalog = new PluginDrivenExternalCatalog( catalogId, name, resource, props, comment, spiConnector); - } else if (SPI_READY_TYPES.contains(catalogType)) { - // SPI-only type but no connector provider loaded. - if (isReplay) { - // During replay we must not throw — FE startup would be blocked. - // Register a degraded catalog; it will throw at first access with a - // clear error message from initLocalObjectsImpl(). - LOG.warn("No SPI connector plugin loaded for type '{}'. Catalog '{}' will be " - + "registered in degraded mode until the plugin is available.", - catalogType, name); - catalog = new PluginDrivenExternalCatalog( - catalogId, name, resource, props, comment, null); - } else { - throw new DdlException("No connector plugin loaded for catalog type '" - + catalogType + "'. Ensure the connector plugin is installed in the " - + "plugin directory configured by connector_plugin_root."); - } - } - - // Fallback to built-in catalog types if no SPI connector matched. - if (catalog == null) { + } else { + // No plugin claimed it: try the catalog types the engine implements itself. Keep the set of names + // in BUILTIN_CATALOG_TYPES above in sync with the cases here. switch (catalogType) { - // hms and iceberg are routed through the SPI connector path (SPI_READY_TYPES) and never reach - // this built-in fallback; their legacy built-in cases were removed at the GSON cutover (their - // GSON subtypes are now registerCompatibleSubtype-only -> PluginDriven). case "lakesoul": throw new DdlException("Lakesoul catalog is no longer supported"); case "doris": @@ -154,7 +136,24 @@ private static CatalogIf createCatalog(long catalogId, String name, String resou catalogId, name, resource, props, comment); break; default: - throw new DdlException("Unknown catalog type: " + catalogType); + // Neither a connector plugin nor the engine knows this type. + if (isReplay) { + // During replay we must not throw: the edit-log replay fallback turns an exception here + // into System.exit(-1), so a missing plugin would keep the whole FE from starting + // instead of just making one catalog unusable. Register a degraded catalog; it throws + // at first access with a clear message from initLocalObjectsImpl(). + LOG.warn("No connector plugin claimed catalog type '{}'. Catalog '{}' will be " + + "registered in degraded mode until the plugin is available.", + catalogType, name); + catalog = new PluginDrivenExternalCatalog( + catalogId, name, resource, props, comment, null); + } else { + throw new DdlException("No connector plugin claimed catalog type '" + catalogType + + "'. Installed connector types: " + ConnectorFactory.getStandaloneCatalogTypes() + + ". Ensure the connector plugin is installed in the plugin directory " + + "configured by connector_plugin_root."); + } + break; } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogIf.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogIf.java index 31047ad43245e8..06a0ec1adc93c4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogIf.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogIf.java @@ -188,6 +188,32 @@ default CatalogLog constructEditLog() { void dropDb(String dbName, boolean ifExists, boolean force) throws DdlException; + /** + * Validates an explicitly written {@code CREATE TABLE ... ENGINE=} against this catalog, throwing + * if this catalog does not create tables under that engine name. + * + *

      {@code ENGINE=} is legacy syntax that predates catalogs, and it is optional — omitting it is always + * legal and lets the catalog pick for itself. The engine therefore keeps no table of which name belongs + * to which data source: every catalog answers for itself, and an external catalog forwards the question + * to its connector. The default rejects any explicit engine, which is the right answer for a catalog that + * cannot create tables at all.

      + * + *

      Implementations must not force the catalog to initialize: this runs during analysis, where a remote + * round trip would turn a syntax mistake into a connection error.

      + */ + default void validateCreateTableEngine(String engineName) throws AnalysisException { + throw new AnalysisException(engineMismatchError(engineName, getName())); + } + + /** + * The one wording for "that engine name is not this catalog's", so every catalog rejects alike. It names + * only what the user wrote and the catalog they wrote it against — deliberately not the engine name that + * would have been accepted, which is the connector's to know, not the engine's. + */ + static String engineMismatchError(String engineName, String catalogName) { + return "Engine '" + engineName + "' does not match catalog '" + catalogName + "'."; + } + /** * @return if org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo.ifNotExists is true, * return true if table exists, diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogProperty.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogProperty.java index 138e9f320bbd36..b50b227ca4a37f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogProperty.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogProperty.java @@ -235,7 +235,7 @@ public void setPluginDerivedStorageDefaultsSupplier(Supplier /** * The effective raw storage property map for a plugin catalog to bind directly through fe-filesystem - * (design S2), letting {@code ConnectorContext.getStorageProperties()} hand the connector typed + * (design S2), letting {@code ConnectorStorageContext.getStorageProperties()} hand the connector typed * fe-filesystem storage without the redundant fe-core {@link StorageProperties#createAll} round-trip. * Returns the same map {@link #initStorageProperties} would parse — user props plus derived defaults * (warehouse -> fs.defaultFS). Design S4: no vended discrimination — fe-core hands the connector the raw diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java index 6a43988ccfdf14..007b4f63420d66 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/InternalCatalog.java @@ -28,7 +28,6 @@ import org.apache.doris.analysis.SinglePartitionDesc; import org.apache.doris.backup.RestoreJob; import org.apache.doris.catalog.BinlogConfig; -import org.apache.doris.catalog.BrokerTable; import org.apache.doris.catalog.ColocateGroupSchema; import org.apache.doris.catalog.ColocateTableIndex; import org.apache.doris.catalog.ColocateTableIndex.GroupId; @@ -58,7 +57,6 @@ import org.apache.doris.catalog.MetaIdGenerator.IdGeneratorBuffer; import org.apache.doris.catalog.MysqlCompatibleDatabase; import org.apache.doris.catalog.MysqlDb; -import org.apache.doris.catalog.MysqlTable; import org.apache.doris.catalog.OlapTable; import org.apache.doris.catalog.OlapTable.OlapTableState; import org.apache.doris.catalog.OlapTableFactory; @@ -1208,6 +1206,26 @@ public void replayDeleteReplica(ReplicaPersistInfo info) throws MetaNotFoundExce } } + /** + * The internal catalog creates olap tables. It still recognizes the three engine names it used to serve + * itself — {@code odbc} / {@code mysql} / {@code broker} — purely to keep answering with the retirement + * message that tells the user where those table types went; every other name is somebody else's. + */ + @Override + public void validateCreateTableEngine(String engineName) throws AnalysisException { + if (CreateTableInfo.ENGINE_OLAP.equals(engineName)) { + return; + } + if (CreateTableInfo.ENGINE_ODBC.equals(engineName) + || CreateTableInfo.ENGINE_MYSQL.equals(engineName) + || CreateTableInfo.ENGINE_BROKER.equals(engineName)) { + throw new AnalysisException("odbc, mysql and broker table is no longer supported." + + " For odbc and mysql external table, use jdbc table or jdbc catalog instead." + + " For broker table, use table valued function instead."); + } + throw new AnalysisException(CatalogIf.engineMismatchError(engineName, getName())); + } + /** * Following is the step to create an olap table: * 1. create columns @@ -1265,37 +1283,13 @@ public boolean createTable(CreateTableInfo createTableInfo) throws UserException ErrorCode.ERR_TABLE_EXISTS_ERROR, tableName); } - if (engineName.equals("olap")) { - return createOlapTable(db, createTableInfo); - } - if (engineName.equals("odbc")) { - throw new DdlException( - "ODBC table is no longer supported. Please use JDBC Catalog instead."); - } - if (engineName.equals("mysql")) { - return createMysqlTable(db, createTableInfo); - } - if (engineName.equals("broker")) { - return createBrokerTable(db, createTableInfo); - } - if (engineName.equalsIgnoreCase("elasticsearch") || engineName.equalsIgnoreCase("es")) { - throw new UserException( - "Cannot create Elasticsearch table in internal catalog. Please use ES Catalog instead."); - } - if (engineName.equalsIgnoreCase("hive")) { - // should use hive catalog to create external hive table - throw new UserException("Cannot create hive table in internal catalog, should switch to hive catalog."); - } - if (engineName.equalsIgnoreCase("jdbc")) { - throw new DdlException( - "JDBC table is no longer supported. Please use JDBC Catalog instead."); - - } else { + // Only olap can reach here: validateCreateTableEngine already rejected every other name during + // analysis, including the retired odbc/mysql/broker and the external engines a user aimed at the wrong + // catalog. The check stays as a guard against a caller that skips analysis. + if (!CreateTableInfo.ENGINE_OLAP.equals(engineName)) { ErrorReport.reportDdlException(ErrorCode.ERR_UNKNOWN_STORAGE_ENGINE, engineName); } - - Preconditions.checkState(false); - return false; + return createOlapTable(db, createTableInfo); } public void replayCreateTable(String dbName, long dbId, Table table) throws MetaNotFoundException { @@ -3313,28 +3307,6 @@ private boolean createOlapTable(Database db, CreateTableInfo createTableInfo) th return tableHasExist; } - private boolean createMysqlTable(Database db, CreateTableInfo createTableInfo) throws DdlException { - String tableName = createTableInfo.getTableName(); - List columns = createTableInfo.getColumns(); - long tableId = Env.getCurrentEnv().getNextId(); - MysqlTable mysqlTable = new MysqlTable(tableId, tableName, columns, createTableInfo.getProperties()); - mysqlTable.setComment(createTableInfo.getComment()); - Pair result = db.createTableWithLock(mysqlTable, false, createTableInfo.isIfNotExists()); - return checkCreateTableResult(tableName, tableId, result); - } - - private boolean createBrokerTable(Database db, CreateTableInfo createTableInfo) throws DdlException { - String tableName = createTableInfo.getTableName(); - - List columns = createTableInfo.getColumns(); - - long tableId = Env.getCurrentEnv().getNextId(); - BrokerTable brokerTable = new BrokerTable(tableId, tableName, columns, createTableInfo.getProperties()); - brokerTable.setComment(createTableInfo.getComment()); - brokerTable.setBrokerProperties(createTableInfo.getExtProperties()); - Pair result = db.createTableWithLock(brokerTable, false, createTableInfo.isIfNotExists()); - return checkCreateTableResult(tableName, tableId, result); - } private boolean checkCreateTableResult(String tableName, long tableId, Pair result) throws DdlException { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java index 5f5fd47f8eaf7c..055b8ea96b03a9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java @@ -22,10 +22,12 @@ import org.apache.doris.catalog.Env; import org.apache.doris.common.Config; import org.apache.doris.common.util.MasterDaemon; +import org.apache.doris.connector.ConnectorFactory; import org.apache.doris.connector.api.event.ConnectorEventSource; import org.apache.doris.connector.api.event.EventPollRequest; import org.apache.doris.connector.api.event.EventPollResult; import org.apache.doris.connector.api.event.MetastoreChangeDescriptor; +import org.apache.doris.connector.spi.ConnectorProvider; import org.apache.doris.datasource.log.CatalogLog; import org.apache.doris.datasource.log.MetaIdMappingsLog; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; @@ -96,6 +98,42 @@ protected void runAfterCatalogReady() { isRunning = false; } + /** + * One-shot force-initialization of a catalog nobody has queried on this FE, so it can obtain its event + * source and seed its cursor. + * + *

      Flip-time force-init parity: the legacy {@code MetastoreEventsProcessor} force-initialized EVERY hms + * catalog every cycle on every FE (via {@code getHmsProperties() -> makeSureInitialized()}), so a flipped + * hms catalog seeds its cursor even if it is never queried on this FE. That is required on followers too + * (each FE runs its own driver with its own cursor, and a follower must have the catalog initialized to + * obtain its event source, seed its cursor and forward {@code REFRESH CATALOG}) — hence no isMaster gate. + * + *

      Only types that DECLARE an event source get this, so idle paimon/iceberg/jdbc catalogs stay + * byte-inert. The declaration is read off the connector PROVIDER, keyed on the pre-init type string: + * {@code getType()} reads catalogProperty and does NOT force-init, whereas asking the connector itself + * would force-initialize exactly the idle catalogs this check exists to leave alone. The caller guards on + * {@code !isInitialized()}, so this is one-shot per catalog — later cycles take the initialized path. + * + * @return whether the catalog is now initialized and can be polled this cycle + */ + boolean seedCursorOfUninitializedCatalog(PluginDrivenExternalCatalog pluginCatalog) { + boolean declaresEventSource = ConnectorFactory + .findProvider(pluginCatalog.getType(), pluginCatalog.getProperties()) + .map(ConnectorProvider::providesEventSource) + .orElse(false); + if (!declaresEventSource) { + return false; + } + try { + pluginCatalog.makeSureInitialized(); + } catch (Exception e) { + // Missing/invalid params this cycle -> skip (mirrors the legacy skip-on-throw around + // getHmsProperties()); retried next cycle, the error is already surfaced via SHOW CATALOGS. + return false; + } + return true; + } + private void realRun() { List catalogIds = Env.getCurrentEnv().getCatalogMgr().getCatalogIds(); for (Long catalogId : catalogIds) { @@ -104,28 +142,8 @@ private void realRun() { continue; } PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalog; - if (!pluginCatalog.isInitialized()) { - // Flip-time force-init parity: the legacy MetastoreEventsProcessor force-initialized EVERY hms - // catalog every cycle on every FE (via getHmsProperties() -> makeSureInitialized()), so a flipped - // hms catalog seeds its cursor even if it is never queried on this FE. This is required on - // followers too (each FE runs its own driver with its own cursor, and a follower must have the - // catalog initialized to obtain its event source, seed its cursor and forward REFRESH CATALOG) — - // hence no isMaster gate. Mirror that ONLY for the event-source type ("hms", the sole connector - // exposing a ConnectorEventSource), keyed on the pre-init type string so idle paimon/iceberg/ - // jdbc/hudi PluginDriven catalogs stay byte-inert: getType() reads catalogProperty and does NOT - // force-init. Guarded by !isInitialized(), so it is a one-shot per catalog (subsequent cycles - // take the initialized fast path). Pre-flip there is no hms-typed PluginDriven catalog, so this - // block matches nothing and the driver stays dormant. - if (!"hms".equalsIgnoreCase(pluginCatalog.getType())) { - continue; - } - try { - pluginCatalog.makeSureInitialized(); - } catch (Exception e) { - // Missing/invalid params this cycle -> skip (mirrors the legacy skip-on-throw around - // getHmsProperties()); retried next cycle, the error is already surfaced via SHOW CATALOGS. - continue; - } + if (!pluginCatalog.isInitialized() && !seedCursorOfUninitializedCatalog(pluginCatalog)) { + continue; } ConnectorEventSource eventSource; try { diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/TablePartitionValues.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/TablePartitionValues.java index 008fa940178910..d0c75c3ce477d4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/TablePartitionValues.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/TablePartitionValues.java @@ -24,6 +24,7 @@ import org.apache.doris.catalog.Type; import org.apache.doris.common.AnalysisException; import org.apache.doris.common.lock.MonitoredReentrantReadWriteLock; +import org.apache.doris.connector.api.scan.ConnectorPartitionValues; import org.apache.doris.planner.ColumnBound; import org.apache.doris.planner.ListPartitionPrunerV2; import org.apache.doris.planner.PartitionPrunerV2Base.UniqueId; @@ -44,8 +45,6 @@ @Data public class TablePartitionValues { - public static final String HIVE_DEFAULT_PARTITION = "__HIVE_DEFAULT_PARTITION__"; - private final MonitoredReentrantReadWriteLock readWriteLock; private long lastUpdateTimestamp; private long nextPartitionId; @@ -159,7 +158,8 @@ private ListPartitionItem toListPartitionItem(List partitionValues, List Preconditions.checkState(partitionValues.size() == types.size()); try { PartitionKey key = PartitionKey.createListPartitionKeyWithTypes( - partitionValues.stream().map(p -> new PartitionValue(p, HIVE_DEFAULT_PARTITION.equals(p))) + partitionValues.stream() + .map(p -> new PartitionValue(p, ConnectorPartitionValues.NULL_PARTITION_NAME.equals(p))) .collect(Collectors.toList()), types, false); return new ListPartitionItem(Lists.newArrayList(key)); } catch (AnalysisException e) { 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 c1ae9c2bec6bb2..86ca2b4dd4a00f 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 @@ -28,6 +28,7 @@ import org.apache.doris.catalog.info.DropTagInfo; import org.apache.doris.catalog.info.PartitionNamesInfo; import org.apache.doris.catalog.info.TableNameInfo; +import org.apache.doris.common.AnalysisException; import org.apache.doris.common.DdlException; import org.apache.doris.common.ErrorCode; import org.apache.doris.common.ErrorReport; @@ -50,6 +51,8 @@ import org.apache.doris.connector.api.ddl.PartitionFieldChange; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.ddl.CreateTableInfoToConnectorRequestConverter; +import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.CatalogMgr; import org.apache.doris.datasource.CatalogProperty; import org.apache.doris.datasource.ExternalCatalog; @@ -110,6 +113,9 @@ public class PluginDrivenExternalCatalog extends ExternalCatalog { // (the lightweight CatalogFactory context is not tracked here; its FS is never built). private transient volatile DefaultConnectorContext connectorContext; + // The displayed engine name, resolved from the provider on first use (see getDisplayEngineName). + private transient volatile String displayEngineName; + /** No-arg constructor for GSON deserialization. */ public PluginDrivenExternalCatalog() { } @@ -201,7 +207,10 @@ protected Connector createConnectorFromProperties() { () -> catalogProperty.getStoragePropertiesMap(), catalogProperty::getEffectiveRawStorageProperties); this.connectorContext = context; - return ConnectorFactory.createConnector(catalogType, catalogProperty.getProperties(), context); + // The standalone entry point, same as CatalogFactory uses: this is the second door onto a catalog (the + // lazy build after image deserialization), and both doors must agree on what may become a catalog. + return ConnectorFactory.createStandaloneCatalogConnector( + catalogType, catalogProperty.getProperties(), context); } @Override @@ -313,6 +322,8 @@ protected List listTableNamesFromRemote(SessionContext ctx, String dbNam ConnectorSession session = buildCrossStatementSession(); ConnectorMetadata metadata = PluginDrivenMetadata.get(session, connector); List tableNames = metadata.listTableNames(session, dbName); + // Deliberately the raw field, NOT hasConnectorCapability(): this already runs inside an initialized + // catalog, so re-entering makeSureInitialized() here would be pointless work on a listing path. if (!connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_VIEW)) { return tableNames; } @@ -343,12 +354,79 @@ public String getType() { return catalogProperty.getOrDefault(CatalogMgr.CATALOG_TYPE_PROP, super.getType()); } + /** + * The engine name this catalog's tables display, asked of the connector's provider so that the + * engine holds no mapping from data source to displayed name. Falls back to the catalog type when no + * provider claims it, which is also the provider's own default — a catalog whose plugin is not installed + * therefore still displays what it displayed before. + * + *

      Resolved once and remembered. Both callers ({@code PluginDrivenExternalTable.getEngine} and + * {@code getEngineTableTypeName}) sit in a per-table loop — {@code FrontendServiceImpl.listTableStatus} + * calls this for every table of a database — and {@link #getProperties()} copies the whole property map on + * every call, so resolving per table would copy that map per table. Remembering is safe because the + * provider set is fixed for the life of the FE: {@code Env.initConnectorPluginManager} runs once at + * startup, before any catalog is touched, and nothing re-registers afterwards. The field is transient, so + * after a restart the first caller recomputes it — a local lookup among loaded plugins that touches + * nothing remote and cannot force this catalog to initialize.

      + */ + public String getDisplayEngineName() { + String name = displayEngineName; + if (name == null) { + String type = getType(); + name = ConnectorFactory.findProvider(type, getProperties()) + .map(ConnectorProvider::displayEngineName) + .orElse(type); + displayEngineName = name; + } + return name; + } + /** Returns the underlying SPI connector. Ensures the catalog is initialized first. */ public Connector getConnector() { makeSureInitialized(); return connector; } + /** + * Whether the backing connector declares {@code capability} catalog-wide. The single entry point for the + * capability checks that do NOT have a table in hand — the alternative is each caller repeating + * {@code getConnector() != null && getConnector().getCapabilities().contains(...)}, which is how one of + * them ended up without the null check and throwing instead of rejecting cleanly. + * + *

      Forces initialization (via {@link #getConnector()}), so it is for callers OUTSIDE this class, + * which already paid that cost. Code inside this catalog that runs before or during initialization must + * keep reading the {@code connector} field directly: routing it here would make a capability check + * initialize the catalog, which is exactly what those sites avoid.

      + * + *

      Only the capabilities {@link ConnectorCapability} documents as catalog-scoped belong here; a + * table-scoped one is resolved by {@code PluginDrivenExternalTable} instead, as the union of this set and + * the table's own.

      + */ + public boolean hasConnectorCapability(ConnectorCapability capability) { + Connector conn = getConnector(); + return conn != null && conn.getCapabilities().contains(capability); + } + + /** + * Answers from the connector's provider, not the connector: this runs while a statement is being + * analyzed, and {@link #getConnector()} would force the catalog to initialize, turning a mistyped engine + * name into a metastore connection error. Provider lookup is a lookup among already-registered plugins, + * keyed on the persisted catalog type, and touches nothing remote. + * + *

      A catalog whose plugin is not installed has no provider, so every explicit engine is rejected with + * the same mismatch message the base interface produces — the missing-plugin diagnosis still comes later, + * from initialization, exactly as it does today.

      + */ + @Override + public void validateCreateTableEngine(String engineName) throws AnalysisException { + boolean accepted = ConnectorFactory.findProvider(getType(), getProperties()) + .map(provider -> provider.acceptedCreateTableEngineNames().contains(engineName)) + .orElse(false); + if (!accepted) { + throw new AnalysisException(CatalogIf.engineMismatchError(engineName, getName())); + } + } + /** * Registers a newly-observed database into this catalog, driven by the metastore-event sync's * REGISTER_DATABASE change (via {@code CatalogMgr.registerExternalDatabaseFromEvent}). Pulled up from @@ -484,11 +562,10 @@ public boolean createTable(CreateTableInfo createTableInfo) throws UserException * {@code ConnectorSchemaOps.createDatabase(session, dbName, properties)}. * *

      The SPI signature carries no {@code ifNotExists}; this override honors it - * FE-side. It short-circuits on the local FE cache, and — for connectors that - * support CREATE DATABASE ({@code supportsCreateDatabase()}) — also consults the - * remote {@code databaseExists} so {@code CREATE DATABASE IF NOT EXISTS} on a - * database that exists remotely but is not yet in this FE's cache cleanly no-ops - * instead of surfacing a remote "already exists" error (mirroring legacy + * FE-side. It short-circuits on the local FE cache and then on the remote + * {@code databaseExists}, so {@code CREATE DATABASE IF NOT EXISTS} on a database + * that exists remotely but is not yet in this FE's cache cleanly no-ops instead of + * surfacing a remote "already exists" error (mirroring legacy * {@code MaxComputeMetadataOps.createDbImpl}, which checked both). On success it * writes the edit log and invalidates the cached db-name list (mirroring the * legacy {@code metadataOps.afterCreateDb()} the plugin path no longer has).

      @@ -505,10 +582,12 @@ public void createDb(String dbName, boolean ifNotExists, Map pro // 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 - // that remote check. Gated on supportsCreateDatabase() so connectors that cannot create - // databases (jdbc/es/trino) keep their prior behavior (fall through to createDatabase -> - // "not supported"); the && short-circuit means they never even issue the remote query. - if (ifNotExists && metadata.supportsCreateDatabase() && metadata.databaseExists(session, dbName)) { + // that remote check. Asked of EVERY connector, mirroring Trino's CreateSchemaTask: IF NOT + // EXISTS means "ensure it is there", so a connector that cannot create databases but reports + // this one as existing has already satisfied the request and must not be made to fail. + // A connector that answers neither question keeps the default databaseExists() == false and + // falls through to createDatabase() -> "CREATE DATABASE not supported", as before. + if (ifNotExists && metadata.databaseExists(session, dbName)) { LOG.info("create database[{}] which already exists remotely, skip", dbName); return; } @@ -1285,6 +1364,8 @@ public ConnectorSession buildCrossStatementSession() { * injection above and the shared-cache bypass ({@link #shouldBypassTableNameCache}). */ private boolean supportsUserSession() { + // Deliberately the raw field, NOT hasConnectorCapability(): this runs while building a session and on + // the cache-bypass path, where forcing initialization would be an init-order inversion. return connector != null && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_USER_SESSION); } 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 324d72ab886d95..8d32afa018708d 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 @@ -51,15 +51,13 @@ protected PluginDrivenExternalTable buildTableInternal(String remoteTableName, // Capability gate: connectors that expose a point-in-time snapshot (e.g. Paimon) declare // SUPPORTS_MVCC_SNAPSHOT and get the MVCC/MTMV-capable subclass. The plain plugin connectors // (jdbc/es/max_compute/trino-connector) do NOT declare it and keep the base class, which has - // no MTMV/MvccTable behavior. getConnector() forces init (makeSureInitialized) and returns the - // built connector; the null check is a defensive fallback to the base class for a not-yet-built - // or failed connector (post-init it is normally non-null — initLocalObjectsImpl throws on null). - if (catalog instanceof PluginDrivenExternalCatalog) { - Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); - if (connector != null - && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT)) { - return new PluginDrivenMvccExternalTable(tblId, localTableName, remoteTableName, catalog, db); - } + // no MTMV/MvccTable behavior. hasConnectorCapability forces init (makeSureInitialized) and degrades to + // false for a not-yet-built or failed connector, falling back to the base class (post-init the + // connector is normally non-null — initLocalObjectsImpl throws on null). + if (catalog instanceof PluginDrivenExternalCatalog + && ((PluginDrivenExternalCatalog) catalog) + .hasConnectorCapability(ConnectorCapability.SUPPORTS_MVCC_SNAPSHOT)) { + return new PluginDrivenMvccExternalTable(tblId, localTableName, remoteTableName, catalog, db); } return new PluginDrivenExternalTable(tblId, localTableName, remoteTableName, catalog, db); } 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 1a239c78969dea..5c8734618358a8 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 @@ -172,10 +172,10 @@ public boolean supportsParallelWrite() { if (!(catalog instanceof PluginDrivenExternalCatalog)) { return false; } - Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + ConnectorWritePlanProvider provider = writePlanProvider(); // requiresParallelWrite is byte-inert for a heterogeneous gateway (hive and iceberg both true), so the // connector-level answer needs no per-handle resolution here. - return connector != null && connector.requiresParallelWrite(); + return provider != null && provider.requiresParallelWrite(); } /** @@ -184,6 +184,18 @@ public boolean supportsParallelWrite() { * capabilities per-table (its iceberg tables differ from its hive tables); a single-format connector ignores * the handle (the per-handle overloads default to connector-level), so this is byte-identical for it. */ + /** + * The CONNECTOR-LEVEL write plan provider, or null when this catalog's connector is absent or declares no + * write support. Callers must have already checked that the catalog is plugin-driven. Used by the write + * traits whose answer is the same for every table of a heterogeneous gateway, so paying for a per-handle + * resolution would buy nothing; the per-table ones go through + * {@link #resolveWriteCapabilityHandle(Connector)} and {@code getWritePlanProvider(handle)} instead. + */ + private ConnectorWritePlanProvider writePlanProvider() { + Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); + return connector == null ? null : connector.getWritePlanProvider(); + } + private Optional resolveWriteCapabilityHandle(Connector connector) { ConnectorSession session = ((PluginDrivenExternalCatalog) catalog).buildConnectorSession(); return resolveConnectorTableHandle(session, PluginDrivenMetadata.get(session, connector)); @@ -203,7 +215,8 @@ public Set connectorSupportedWriteOperations() { return EnumSet.noneOf(WriteOperation.class); } return resolveWriteCapabilityHandle(connector) - .map(connector::supportedWriteOperations) + .map(connector::getWritePlanProvider) + .map(ConnectorWritePlanProvider::supportedOperations) .orElseGet(() -> EnumSet.noneOf(WriteOperation.class)); } @@ -220,14 +233,15 @@ public boolean connectorSupportsWriteBranch() { return false; } return resolveWriteCapabilityHandle(connector) - .map(connector::supportsWriteBranch) + .map(connector::getWritePlanProvider) + .map(ConnectorWritePlanProvider::supportsWriteBranch) .orElse(false); } /** * Returns whether THIS table supports background per-column auto-analyze. The statistics auto-collector * consults this (in place of the legacy {@code instanceof IcebergExternalTable} whitelist) to admit a flipped - * plugin table into the auto-analyze framework. Resolved per-table via {@link #hasScanCapability} (not the + * plugin table into the auto-analyze framework. Resolved per-table via {@link #hasCapability} (not the * connector-wide set alone) so a heterogeneous hive catalog can express the legacy * {@code StatisticsUtil.supportAutoAnalyze} gate of {@code dlaType HIVE || ICEBERG} but NOT {@code HUDI}: a * uniform-format connector (native iceberg/paimon) still declares it connector-wide, while hive emits it @@ -236,19 +250,19 @@ public boolean connectorSupportsWriteBranch() { * withheld. Mirrors {@link #supportsTopNLazyMaterialize} / {@link #supportsNestedColumnPrune}. */ public boolean supportsColumnAutoAnalyze() { - return hasScanCapability(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE); + return hasCapability(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE); } /** * Returns whether THIS table supports Top-N lazy materialization. The nereids Top-N lazy-materialize probe * consults this (in place of the legacy exact-class {@code SUPPORT_RELATION_TYPES} membership) to enable - * lazy materialization for a flipped plugin table. Resolved per-table via {@link #hasScanCapability}: a + * lazy materialization for a flipped plugin table. Resolved per-table via {@link #hasCapability}: a * uniform-format connector (iceberg) declares it connector-wide, a heterogeneous connector (hive) emits it * only for its orc/parquet tables — so a hive text/csv/json/view table is correctly excluded, as it was in * legacy {@code MaterializeProbeVisitor}. */ public boolean supportsTopNLazyMaterialize() { - return hasScanCapability(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE); + return hasCapability(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE); } /** @@ -256,12 +270,12 @@ public boolean supportsTopNLazyMaterialize() { * sub-fields). The nereids nested-column-prune probe ({@code LogicalFileScan.supportPruneNestedColumn}) * consults this (in place of the legacy exact-class {@code IcebergExternalTable} arm) to enable pruning for a * flipped plugin table, and the {@code SlotTypeReplacer} name-to-field-id rewrite is gated on the same - * answer. Resolved per-table via {@link #hasScanCapability} for the same reason as Top-N: legacy gated it on + * answer. Resolved per-table via {@link #hasCapability} for the same reason as Top-N: legacy gated it on * the per-table file format (parquet/orc only), which a connector-wide capability cannot express for a * heterogeneous hive catalog. */ public boolean supportsNestedColumnPrune() { - return hasScanCapability(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE); + return hasCapability(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE); } /** @@ -269,37 +283,41 @@ public boolean supportsNestedColumnPrune() { * nested paths and {@code MODIFY COLUMN ... COMMENT}). The nereids {@code AlterTableCommand} column-op * validation consults this (in place of the legacy exact-class {@code IcebergExternalTable} gate) to admit * the Iceberg-style clause set and to allow nested {@code ColumnPath} targets. Resolved per-table via - * {@link #hasScanCapability} so an iceberg-on-HMS table inherits it through the reflected per-table + * {@link #hasCapability} so an iceberg-on-HMS table inherits it through the reflected per-table * capability set, mirroring {@link #supportsNestedColumnPrune}. */ public boolean supportsNestedColumnSchemaChange() { - return hasScanCapability(ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE); + return hasCapability(ConnectorCapability.SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE); } /** * Returns whether THIS table supports {@code ANALYZE ... WITH SAMPLE}. Consulted by * {@code AnalysisManager.canSample}, {@code AnalyzeTableCommand.isSamplingPartition}, {@link * #createAnalysisTask} (to return a sample-capable task) and the background auto-analyze method choice. - * Resolved per-table via {@link #hasScanCapability}: hive emits it for its plain-hive tables only (legacy + * Resolved per-table via {@link #hasCapability}: hive emits it for its plain-hive tables only (legacy * {@code dlaType==HIVE}), so iceberg/hudi-on-HMS are excluded; native iceberg/paimon never declare it (their * {@code doSample} is unimplemented), keeping their current build-time reject. Mirrors * {@link #supportsTopNLazyMaterialize}. */ public boolean supportsSampleAnalyze() { - return hasScanCapability(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE); + return hasCapability(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE); } /** - * Whether this table supports a per-table-refinable scan-planning capability, resolved connector-wide OR - * per-table. A uniform-format connector (iceberg — every table orc/parquet) declares the capability for all - * its tables via {@link Connector#getCapabilities()}; a heterogeneous connector (hive) whose eligibility is - * per-table file-format gated instead emits the capability name per-table in the - * {@link ConnectorTableSchema#PER_TABLE_CAPABILITIES_KEY} schema marker, read here from the already-cached - * schema (no remote round-trip). The two sources are additive, so single-format connectors never emit the - * marker and behave exactly as before. fe-core never inspects the file format — the connector decides which - * of its tables qualify by emitting (or not) the capability name. + * Whether this table supports a table-scoped capability, resolved connector-wide OR per-table. A + * uniform-format connector (iceberg — every table orc/parquet) declares the capability for all its tables + * via {@link Connector#getCapabilities()}; a heterogeneous connector (hive) whose eligibility is per-table + * file-format gated instead declares it per-table in {@link ConnectorTableSchema#getTableCapabilities()}, + * read here from the already-cached schema (no remote round-trip). The two sources are additive, so + * single-format connectors declare nothing per-table and behave exactly as before. fe-core never inspects + * the file format — the connector decides which of its tables qualify. + * + *

      Only the capabilities {@link ConnectorCapability} documents as table-scoped may be resolved through + * here. Routing a catalog-scoped one through it would be a behaviour change, and for two of them a + * damaging one: reading the per-table set touches the schema cache, and those two are consulted while the + * table is being initialized / in order to decide whether to load metadata at all.

      */ - private boolean hasScanCapability(ConnectorCapability capability) { + private boolean hasCapability(ConnectorCapability capability) { if (!(catalog instanceof PluginDrivenExternalCatalog)) { return false; } @@ -307,19 +325,15 @@ private boolean hasScanCapability(ConnectorCapability capability) { if (connector == null) { return false; } - if (connector.getCapabilities().contains(capability)) { - return true; - } - String csv = rawTableProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY); - if (csv == null || csv.isEmpty()) { - return false; - } - for (String name : csv.split(",")) { - if (name.trim().equals(capability.name())) { - return true; - } - } - return false; + return connector.getCapabilities().contains(capability) || tableCapabilities().contains(capability); + } + + /** The connector-declared per-table capability set, from the cached schema; empty on any miss. */ + private Set tableCapabilities() { + makeSureInitialized(); + return getSchemaCacheValue() + .map(value -> ((PluginDrivenSchemaCacheValue) value).getTableCapabilities()) + .orElse(Collections.emptySet()); } /** @@ -363,8 +377,8 @@ public boolean requirePartitionLocalSortOnWrite() { if (!(catalog instanceof PluginDrivenExternalCatalog)) { return false; } - Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); - return connector != null && connector.requiresPartitionLocalSort(); + ConnectorWritePlanProvider provider = writePlanProvider(); + return provider != null && provider.requiresPartitionLocalSort(); } /** @@ -385,7 +399,8 @@ public boolean requirePartitionHashOnWrite() { } // Per-table: hive requires partition-hash writes but iceberg does not, so resolve the handle. return resolveWriteCapabilityHandle(connector) - .map(connector::requiresPartitionHashWrite) + .map(connector::getWritePlanProvider) + .map(ConnectorWritePlanProvider::requiresPartitionHashWrite) .orElse(false); } @@ -399,8 +414,8 @@ public boolean requiresFullSchemaWriteOrder() { if (!(catalog instanceof PluginDrivenExternalCatalog)) { return false; } - Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); - return connector != null && connector.requiresFullSchemaWriteOrder(); + ConnectorWritePlanProvider provider = writePlanProvider(); + return provider != null && provider.requiresFullSchemaWriteOrder(); } /** @@ -420,7 +435,8 @@ public boolean materializeStaticPartitionValues() { } // Per-table: iceberg retains partition columns (materialize the PARTITION literal), hive does not. return resolveWriteCapabilityHandle(connector) - .map(connector::requiresMaterializeStaticPartitionValues) + .map(connector::getWritePlanProvider) + .map(ConnectorWritePlanProvider::requiresMaterializeStaticPartitionValues) .orElse(false); } @@ -538,7 +554,7 @@ protected PluginDrivenSchemaCacheValue toSchemaCacheValue(ConnectorMetadata meta } } return new PluginDrivenSchemaCacheValue(columns, partitionColumns, partitionColumnRemoteNames, - tableSchema.getProperties()); + tableSchema.getProperties(), tableSchema.getTableCapabilities()); } @Override @@ -759,9 +775,9 @@ private Map rawTableProperties() { /** * The connector's user-facing table properties (e.g. paimon coreOptions: path / file.format / * write-only), used by SHOW CREATE TABLE to render the PROPERTIES(...) block (D-046). Every FE-internal - * reserved control key ({@link ConnectorTableSchema#RESERVED_CONTROL_KEYS} — the partition-columns / - * primary-keys markers, the SHOW CREATE render hints, and the per-table capability / distribution markers, - * all namespaced under {@code __internal.}) is stripped: they are not user-facing options and must not + * reserved control key ({@link ConnectorTableSchema#RESERVED_CONTROL_KEYS} — the partition-columns and + * distribution-columns markers plus the SHOW CREATE render hints, all namespaced under + * {@code __internal.}) is stripped: they are not user-facing options and must not * leak into the rendered PROPERTIES(...). Because the reserved keys are namespaced, a source table's own * user property can never collide with one, so it flows through here unchanged. */ @@ -802,8 +818,8 @@ public String getShowSortClause() { @Override public boolean supportInternalPartitionPruned() { // Unconditional true, mirroring legacy MaxComputeExternalTable (and IcebergExternalTable). - // This override is shared by every SPI-driven connector (jdbc/es/trino/max_compute via - // CatalogFactory.SPI_READY_TYPES) and true is correct for all of them, partitioned or not: + // This override is shared by every plugin-driven connector (jdbc/es/trino/max_compute among them) + // and true is correct for all of them, partitioned or not: // - partitioned -> PruneFileScanPartition prunes to the surviving partitions; // - non-partitioned -> PruneFileScanPartition takes its IF branch and pruneExternalPartitions // returns NOT_PRUNED for empty partition columns, so the scan reads all. @@ -1264,70 +1280,33 @@ private long estimatedRowWidth(boolean excludePartitionColumns) { return rowWidth; } + /** + * The engine name shown in the {@code ENGINE} column of {@code SHOW TABLE STATUS} and + * {@code information_schema.tables} (and through the REST metadata API). Named by the connector, which + * defaults it to the catalog type; the engine keeps no mapping from data source to displayed name. + * + *

      Falls back to the generic {@code Plugin} only for a table whose catalog is not plugin-driven, which + * no production path builds.

      + */ @Override public String getEngine() { - // Return the legacy engine name based on the actual catalog type, - // not the generic "Plugin" from PLUGIN_EXTERNAL_TABLE.toEngineName(). - // This preserves user-visible compatibility for migrated JDBC/ES tables - // across SHOW TABLE STATUS, information_schema.tables, REST API, etc. - String catalogType = catalog instanceof PluginDrivenExternalCatalog - ? ((PluginDrivenExternalCatalog) catalog).getType() : ""; - switch (catalogType) { - case "jdbc": - return TableType.JDBC_EXTERNAL_TABLE.toEngineName(); - case "es": - return TableType.ES_EXTERNAL_TABLE.toEngineName(); - case "iceberg": - // P6.5-T06: preserve the legacy IcebergExternalTable engine name "iceberg" - // (TableType.ICEBERG_EXTERNAL_TABLE.toEngineName()) for migrated iceberg base/sys tables, - // instead of the generic "Plugin" from PLUGIN_EXTERNAL_TABLE. - return TableType.ICEBERG_EXTERNAL_TABLE.toEngineName(); - case "trino-connector": - // TableType.TRINO_CONNECTOR_EXTERNAL_TABLE.toEngineName() returns null - // (no switch case in TableType.toEngineName), matching legacy behavior. - return TableType.TRINO_CONNECTOR_EXTERNAL_TABLE.toEngineName(); - case "max_compute": - // TableType.MAX_COMPUTE_EXTERNAL_TABLE.toEngineName() returns null - // (no switch case in TableType.toEngineName), matching legacy behavior. - return TableType.MAX_COMPUTE_EXTERNAL_TABLE.toEngineName(); - case "paimon": - // TableType.PAIMON_EXTERNAL_TABLE.toEngineName() returns "paimon", - // preserving the legacy PaimonExternalTable engine name. - return TableType.PAIMON_EXTERNAL_TABLE.toEngineName(); - case "hms": - // Post-flip an HMS external catalog is a PluginDrivenExternalCatalog (type "hms"); - // legacy HMSExternalTable displayed engine "hms" (TableType.HMS_EXTERNAL_TABLE.toEngineName()), - // NOT "hive" — the CREATE-TABLE engine (CreateTableInfo.pluginCatalogTypeToEngine -> "hive") - // is a separate concern. Falling through to "Plugin" would regress SHOW TABLE STATUS / - // information_schema.tables. - return TableType.HMS_EXTERNAL_TABLE.toEngineName(); - default: - return super.getEngine(); - } + return catalog instanceof PluginDrivenExternalCatalog + ? ((PluginDrivenExternalCatalog) catalog).getDisplayEngineName() + : super.getEngine(); } + /** + * What {@code SHOW CREATE TABLE} prints after {@code ENGINE=}. Deliberately the same string as + * {@link #getEngine()}: one connector, one engine name, however the user reaches it. + * + *

      It is display only, and was never round-trippable — an HMS catalog prints {@code hms} here while the + * name it accepts back in {@code CREATE TABLE ... ENGINE=} is {@code hive}. Connectors that render their + * own DDL ({@code ConnectorTableMetadataOps#renderShowCreateTableDdl}, which hive does) never reach this + * at all; their statement carries no {@code ENGINE=} clause.

      + */ @Override public String getEngineTableTypeName() { - String catalogType = catalog instanceof PluginDrivenExternalCatalog - ? ((PluginDrivenExternalCatalog) catalog).getType() : ""; - switch (catalogType) { - case "jdbc": - return TableType.JDBC_EXTERNAL_TABLE.name(); - case "es": - return TableType.ES_EXTERNAL_TABLE.name(); - case "iceberg": - return TableType.ICEBERG_EXTERNAL_TABLE.name(); - case "trino-connector": - return TableType.TRINO_CONNECTOR_EXTERNAL_TABLE.name(); - case "max_compute": - return TableType.MAX_COMPUTE_EXTERNAL_TABLE.name(); - case "paimon": - return TableType.PAIMON_EXTERNAL_TABLE.name(); - case "hms": - return TableType.HMS_EXTERNAL_TABLE.name(); - default: - return TableType.PLUGIN_EXTERNAL_TABLE.name(); - } + return getEngine(); } @Override diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java index 82cea73df617a0..d7a12be9b01d8f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java @@ -18,11 +18,13 @@ package org.apache.doris.datasource.plugin; import org.apache.doris.catalog.Column; +import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.datasource.SchemaCacheValue; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; /** * {@link SchemaCacheValue} for plugin-driven external tables. @@ -54,6 +56,10 @@ public class PluginDrivenSchemaCacheValue extends SchemaCacheValue { // transient ConnectorTableSchema is not kept on the table, so this is the persisted-via-cache // carrier (mirroring how the partition-column views are cached). private final Map tableProperties; + // The capabilities the connector declared for THIS table, on top of its connector-wide set. Same + // rationale as tableProperties: the ConnectorTableSchema is transient, so the schema cache is the + // carrier. Empty for every connector that does not refine per table. + private final Set tableCapabilities; public PluginDrivenSchemaCacheValue(List schema, List partitionColumns, List partitionColumnRemoteNames) { @@ -62,10 +68,17 @@ public PluginDrivenSchemaCacheValue(List schema, List partitionC public PluginDrivenSchemaCacheValue(List schema, List partitionColumns, List partitionColumnRemoteNames, Map tableProperties) { + this(schema, partitionColumns, partitionColumnRemoteNames, tableProperties, Collections.emptySet()); + } + + public PluginDrivenSchemaCacheValue(List schema, List partitionColumns, + List partitionColumnRemoteNames, Map tableProperties, + Set tableCapabilities) { super(schema); this.partitionColumns = partitionColumns; this.partitionColumnRemoteNames = partitionColumnRemoteNames; this.tableProperties = tableProperties == null ? Collections.emptyMap() : tableProperties; + this.tableCapabilities = tableCapabilities == null ? Collections.emptySet() : tableCapabilities; } public List getPartitionColumns() { @@ -79,4 +92,8 @@ public List getPartitionColumnRemoteNames() { public Map getTableProperties() { return tableProperties; } + + public Set getTableCapabilities() { + return tableCapabilities; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java index 571e27437651ac..baa15aa7276104 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java @@ -140,7 +140,7 @@ public static ParsedColumnsFromPath parseColumnsFromPathWithNullInfo( if (index == -1) { continue; } - boolean isNull = ConnectorPartitionValues.HIVE_DEFAULT_PARTITION.equals(pair[1]); + boolean isNull = ConnectorPartitionValues.NULL_PARTITION_NAME.equals(pair[1]); columns[index] = isNull ? "" : pair[1]; columnValueIsNull[index] = isNull; size++; diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java index ffaeabe5180c2a..44a05404ed4cb3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java @@ -78,14 +78,12 @@ import org.apache.logging.log4j.Logger; import java.net.URI; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -114,12 +112,6 @@ public abstract class FileQueryScanNode extends FileScanNode { protected FileSplitter fileSplitter; protected SummaryProfile summaryProfile; - // The data cache function only works for queries on Hive, Iceberg, Hudi(via HMS), and Paimon tables. - // See: https://doris.incubator.apache.org/docs/dev/lakehouse/data-cache - private static final Set CACHEABLE_CATALOGS = new HashSet<>( - Arrays.asList("hms", "iceberg", "paimon") - ); - /** * External file scan node for Query hms table * needCheckColumnPriv: Some of ExternalFileScanNode do not need to check column priv @@ -335,11 +327,6 @@ public TFileScanRangeParams getFileScanRangeParams() { protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { } - // Serialize the table to be scanned to BE's jni reader - protected Optional getSerializedTable() { - return Optional.empty(); - } - @Override public void createScanRangeLocations() throws UserException { long start = System.currentTimeMillis(); @@ -469,8 +456,6 @@ public void createScanRangeLocations() throws UserException { selectedFileNum = distinctFilePaths.size(); } - getSerializedTable().ifPresent(params::setSerializedTable); - if (executor != null) { executor.getSummaryProfile().setCreateScanRangeFinishTime(); if (sessionVariable.showSplitProfileInfo()) { @@ -744,6 +729,19 @@ public TableScanParams getScanParams() { return this.scanParams; } + /** + * Whether BE's file cache applies to what this node scans, and therefore whether the engine's file-cache + * admission governance is meaningful for it. The data this node reads must go through BE's native file + * readers; JNI-read and remote-protocol scans never populate that cache, so evaluating an admission rule + * for them only costs the lookup. + * + *

      Defaults to false, which keeps the table-valued-function and remote-Doris scan nodes out of the + * governance exactly as before. The plugin-driven node answers from the serving connector.

      + */ + protected boolean isFileCacheAdmissionApplicable() { + return false; + } + protected boolean fileCacheAdmissionCheck() throws UserException { boolean admissionResultAtTableLevel = true; TableIf tableIf = getTargetTable(); @@ -754,7 +752,7 @@ protected boolean fileCacheAdmissionCheck() throws UserException { String database = tableIf.getDatabase().getFullName(); String catalog = externalTableIf.getCatalog().getName(); - if (CACHEABLE_CATALOGS.contains(externalTableIf.getCatalog().getType())) { + if (isFileCacheAdmissionApplicable()) { UserIdentity currentUser = ConnectContext.get().getCurrentUserIdentity(); String userIdentity = currentUser.getQualifiedUser() + "@" + currentUser.getHost(); 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 8e6ab63b61b678..94f1379ab6345a 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 @@ -41,14 +41,15 @@ import org.apache.doris.connector.api.pushdown.ConnectorExpression; import org.apache.doris.connector.api.pushdown.ConnectorFilterConstraint; import org.apache.doris.connector.api.pushdown.FilterApplicationResult; -import org.apache.doris.connector.api.pushdown.LimitApplicationResult; import org.apache.doris.connector.api.pushdown.ProjectionApplicationResult; import org.apache.doris.connector.api.scan.ConnectorColumnCategory; import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; import org.apache.doris.connector.api.scan.ConnectorScanProfile; import org.apache.doris.connector.api.scan.ConnectorScanRange; +import org.apache.doris.connector.api.scan.ConnectorScanRequest; import org.apache.doris.connector.api.scan.ConnectorSplitSource; import org.apache.doris.connector.api.scan.ScanNodePropertiesResult; +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; import org.apache.doris.datasource.SchemaCacheValue; import org.apache.doris.datasource.connector.converter.ExprToConnectorExpressionConverter; import org.apache.doris.datasource.mvcc.MvccSnapshot; @@ -123,27 +124,15 @@ public class PluginDrivenScanNode extends FileQueryScanNode { private static final String TABLE_FORMAT_TYPE = "plugin_driven"; - /** Scan node property keys (shared with connector plugins). */ - private static final String PROP_FILE_FORMAT_TYPE = "file_format_type"; - private static final String PROP_PATH_PARTITION_KEYS = "path_partition_keys"; - private static final String PROP_LOCATION_PREFIX = "location."; - private static final String PROP_HIVE_TEXT_PREFIX = "hive.text."; - - // FIX-E (explain gap): synthetic node-property keys threaded into the props map passed to the - // connector's appendExplainInfo, carrying the native/total split counts this node accumulated from - // ConnectorScanRange.isNativeReadRange() in getSplits(). They are NOT real connector properties - // (never reach BE) — only a connector that surfaces a native/JNI split distinction (paimon) reads - // them to emit its "paimonNativeReadSplits=/" line. Byte-identical to the keys - // PaimonScanPlanProvider consumes, so the inject/consume sides stay in lockstep. Connector-agnostic: - // injected for every plugin connector but consumed only by the one that opts in. - private static final String NATIVE_READ_SPLITS_KEY = "__native_read_splits"; - private static final String TOTAL_READ_SPLITS_KEY = "__total_read_splits"; - // FIX-E (explain gap): injected (="true") into the connector's appendExplainInfo props ONLY when this - // node renders a VERBOSE EXPLAIN, so a connector can gate VERBOSE-only output (paimon's per-split - // "PaimonSplitStats:" block) without an SPI signature change. Connector-agnostic: injected for every - // plugin connector but consumed only by the one that opts in. Byte-identical to the key - // PaimonScanPlanProvider consumes. - private static final String VERBOSE_EXPLAIN_KEY = "__explain_verbose"; + // Scan node property keys are declared in the public module, in ScanNodePropertyKeys: both the keys + // this node READS from the connector's property map (file format, path partition keys, location.* and + // the text-family attributes) and the SYNTHETIC_* keys this node INJECTS into the copies of that map it + // passes to appendExplainInfo and populateScanLevelParams. The synthetic ones carry facts only the engine + // knows — the native/total split counts accumulated from ConnectorScanRange.isNativeReadRange(), a VERBOSE + // marker, the pushed-down limit, and whether the connector took ALL the filtering — and are consumed only + // by connectors that opt in (paimon prints split stats; es decides whether to ask its source to stop + // early). They are not real connector properties and are never forwarded to BE. Sharing the constants is + // what keeps the inject/consume sides in lockstep — it used to be a comment. private final Connector connector; private final ConnectorSession connectorSession; @@ -444,7 +433,7 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { output.append(prefix).append("QUERY: ").append(query).append("\n"); } else { Map props = getOrLoadScanNodeProperties(); - String query = props.get("query"); + String query = props.get(ScanNodePropertyKeys.REMOTE_QUERY); output.append(prefix).append("TABLE: ") .append(desc.getTable().getNameWithFullQualifiers()).append("\n"); // Surface the backing connector/catalog type (e.g. es, jdbc, max_compute) so the @@ -536,10 +525,11 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { ConnectorScanPlanProvider scanProvider = resolveScanProvider(); if (scanProvider != null) { Map explainProps = new HashMap<>(props); - explainProps.put(NATIVE_READ_SPLITS_KEY, String.valueOf(nativeReadSplitNum)); - explainProps.put(TOTAL_READ_SPLITS_KEY, String.valueOf(totalReadSplitNum)); + explainProps.put(ScanNodePropertyKeys.SYNTHETIC_NATIVE_READ_SPLITS, String.valueOf(nativeReadSplitNum)); + explainProps.put(ScanNodePropertyKeys.SYNTHETIC_TOTAL_READ_SPLITS, String.valueOf(totalReadSplitNum)); + injectPushdownFacts(explainProps, limit, conjuncts.isEmpty()); if (detailLevel == TExplainLevel.VERBOSE) { - explainProps.put(VERBOSE_EXPLAIN_KEY, "true"); + explainProps.put(ScanNodePropertyKeys.SYNTHETIC_EXPLAIN_VERBOSE, "true"); } onPluginClassLoader(scanProvider, () -> { scanProvider.appendExplainInfo(output, prefix, explainProps); @@ -556,12 +546,6 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { output.append(" (").append(tableLevelRowCount).append(")"); } output.append("\n"); - // Show ES terminate_after optimization when limit is pushed to ES - if (limit > 0 && conjuncts.isEmpty() - && "es_http".equals(props.get(PROP_FILE_FORMAT_TYPE))) { - output.append(prefix).append("ES terminate_after: ") - .append(limit).append("\n"); - } } if (useTopnFilter()) { String topnFilterSources = String.join(",", @@ -575,7 +559,7 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { @Override protected TFileFormatType getFileFormatType() throws UserException { Map props = getOrLoadScanNodeProperties(); - String format = props.get(PROP_FILE_FORMAT_TYPE); + String format = props.get(ScanNodePropertyKeys.FILE_FORMAT_TYPE); if (format != null) { return mapFileFormatType(format); } @@ -585,7 +569,7 @@ protected TFileFormatType getFileFormatType() throws UserException { @Override protected List getPathPartitionKeys() throws UserException { Map props = getOrLoadScanNodeProperties(); - String keys = props.get(PROP_PATH_PARTITION_KEYS); + String keys = props.get(ScanNodePropertyKeys.PATH_PARTITION_KEYS); if (keys != null && !keys.isEmpty()) { return Arrays.asList(keys.split(",")); } @@ -785,8 +769,8 @@ protected Map getLocationProperties() throws UserException { Map props = getOrLoadScanNodeProperties(); Map locationProps = new HashMap<>(); for (Map.Entry entry : props.entrySet()) { - if (entry.getKey().startsWith(PROP_LOCATION_PREFIX)) { - String realKey = entry.getKey().substring(PROP_LOCATION_PREFIX.length()); + if (entry.getKey().startsWith(ScanNodePropertyKeys.LOCATION_PREFIX)) { + String realKey = entry.getKey().substring(ScanNodePropertyKeys.LOCATION_PREFIX.length()); locationProps.put(realKey, entry.getValue()); } } @@ -796,13 +780,13 @@ protected Map getLocationProperties() throws UserException { @Override protected TFileAttributes getFileAttributes() throws UserException { Map props = getOrLoadScanNodeProperties(); - String serDeLib = props.get(PROP_HIVE_TEXT_PREFIX + "serde_lib"); + String serDeLib = props.get(ScanNodePropertyKeys.TEXT_SERDE_LIB); if (serDeLib == null || serDeLib.isEmpty()) { return new TFileAttributes(); } TFileAttributes attrs = new TFileAttributes(); - String skipLinesStr = props.get(PROP_HIVE_TEXT_PREFIX + "skip_lines"); + String skipLinesStr = props.get(ScanNodePropertyKeys.TEXT_SKIP_LINES); if (skipLinesStr != null) { try { attrs.setSkipLines(Integer.parseInt(skipLinesStr)); @@ -812,38 +796,38 @@ protected TFileAttributes getFileAttributes() throws UserException { } TFileTextScanRangeParams textParams = new TFileTextScanRangeParams(); - String colSep = props.get(PROP_HIVE_TEXT_PREFIX + "column_separator"); + String colSep = props.get(ScanNodePropertyKeys.TEXT_COLUMN_SEPARATOR); if (colSep != null) { textParams.setColumnSeparator(colSep); } - String lineSep = props.get(PROP_HIVE_TEXT_PREFIX + "line_delimiter"); + String lineSep = props.get(ScanNodePropertyKeys.TEXT_LINE_DELIMITER); if (lineSep != null) { textParams.setLineDelimiter(lineSep); } - String mapkvDelim = props.get(PROP_HIVE_TEXT_PREFIX + "mapkv_delimiter"); + String mapkvDelim = props.get(ScanNodePropertyKeys.TEXT_MAPKV_DELIMITER); if (mapkvDelim != null) { textParams.setMapkvDelimiter(mapkvDelim); } - String collDelim = props.get(PROP_HIVE_TEXT_PREFIX + "collection_delimiter"); + String collDelim = props.get(ScanNodePropertyKeys.TEXT_COLLECTION_DELIMITER); if (collDelim != null) { textParams.setCollectionDelimiter(collDelim); } - String escape = props.get(PROP_HIVE_TEXT_PREFIX + "escape"); + String escape = props.get(ScanNodePropertyKeys.TEXT_ESCAPE); if (escape != null && !escape.isEmpty()) { textParams.setEscape(escape.getBytes()[0]); } - String nullFmt = props.get(PROP_HIVE_TEXT_PREFIX + "null_format"); + String nullFmt = props.get(ScanNodePropertyKeys.TEXT_NULL_FORMAT); if (nullFmt != null) { textParams.setNullFormat(nullFmt); } - String enclose = props.get(PROP_HIVE_TEXT_PREFIX + "enclose"); + String enclose = props.get(ScanNodePropertyKeys.TEXT_ENCLOSE); if (enclose != null && !enclose.isEmpty()) { textParams.setEnclose(enclose.getBytes()[0]); } // #65501: trimming wrapping double quotes is valid only when the enclose char is '"'. The connector // owns that CSV-serde decision (HiveTextProperties.extractCsvSerDeProps) and passes it as an explicit // flag; the generic node just applies it (rather than trimming for any enclose char). - if ("true".equals(props.get(PROP_HIVE_TEXT_PREFIX + "trim_double_quotes"))) { + if ("true".equals(props.get(ScanNodePropertyKeys.TEXT_TRIM_DOUBLE_QUOTES))) { attrs.setTrimDoubleQuotes(true); } @@ -851,13 +835,13 @@ protected TFileAttributes getFileAttributes() throws UserException { attrs.setHeaderType(""); attrs.setEnableTextValidateUtf8(sessionVariable.enableTextValidateUtf8); - String isJson = props.get(PROP_HIVE_TEXT_PREFIX + "is_json"); + String isJson = props.get(ScanNodePropertyKeys.TEXT_IS_JSON); if ("true".equals(isJson)) { attrs.setReadJsonByLine(true); attrs.setReadByColumnDef(true); // OpenX JSON "ignore.malformed.json": skip malformed rows instead of erroring. The connector emits // this only for the OpenX serde (absent otherwise); mirrors legacy HiveScanNode's openx branch. - String ignoreMalformed = props.get(PROP_HIVE_TEXT_PREFIX + "openx_ignore_malformed"); + String ignoreMalformed = props.get(ScanNodePropertyKeys.TEXT_OPENX_IGNORE_MALFORMED); if (ignoreMalformed != null) { attrs.setOpenxJsonIgnoreMalformed(Boolean.parseBoolean(ignoreMalformed)); } @@ -900,27 +884,6 @@ protected void convertPredicate() { filteredToOriginalIndex = null; } - /** - * Attempts to push the limit down via the SPI applyLimit() protocol. - * Called before getSplits(), after filter pushdown. - * - *

      If the connector accepts the limit, the handle is updated. - * The limit is still passed to planScan() as a parameter for - * connectors that handle limit directly in planScan().

      - */ - private void tryPushDownLimit() { - if (limit <= 0) { - return; - } - ConnectorMetadata metadata = metadata(); - Optional> result = - metadata.applyLimit(connectorSession, currentHandle, limit); - if (result.isPresent()) { - currentHandle = result.get().getHandle(); - LOG.debug("Limit {} pushed down via applyLimit for plugin-driven scan", limit); - } - } - /** * Attempts to push the projection down via the SPI applyProjection() protocol. * Called before getSplits(), after filter and limit pushdown. @@ -971,9 +934,9 @@ public static ConnectorTableHandle applyMvccSnapshotPin(ConnectorMetadata metada /** * Resolves the pinned MVCC snapshot from the statement context and threads it onto - * {@link #currentHandle} (mutates the handle exactly like {@link #tryPushDownProjection} / - * {@link #tryPushDownLimit}). Called at every scan-side handle-consumption point so both the - * split path and the serialized-table path read at the pinned snapshot. + * {@link #currentHandle} (mutates the handle exactly like {@link #tryPushDownProjection}). + * Called at every scan-side handle-consumption point so both the split path and the + * serialized-table path read at the pinned snapshot. */ private void pinMvccSnapshot() throws UserException { ConnectorMetadata metadata = metadata(); @@ -1257,8 +1220,6 @@ boolean sysTableSupportsTimeTravel() { @Override public List getSplits(int numBackends) throws UserException { checkSysTableScanConstraints(); - // Attempt limit and projection pushdown via SPI protocol - tryPushDownLimit(); ConnectorScanPlanProvider scanProvider = resolveScanProvider(); if (scanProvider == null) { @@ -1347,15 +1308,20 @@ public List getSplits(int numBackends) throws UserException { // FileScanNode.toThrift, but a connector that can serve a precomputed row count // (paimon DataSplit.mergedRowCount()) needs the signal here to emit it; otherwise BE // materializes the full post-merge row set just to count. Connectors that do not override the - // count-pushdown overload ignore the flag (default delegates to the 6-arg planScan). + // count-pushdown signal simply ignore this field of the request. // Suppressed under TABLESAMPLE (applySample): a connector that collapses count-eligible splits // into ONE range carrying the precomputed FULL-table count (paimon/iceberg) would ignore the // sample and return full cardinality; with sampling active BE counts rows over the sampled splits // instead (mirrors legacy HiveScanNode, whose tableSample branch precedes the count-only opt). boolean countPushdown = isTableLevelCountStarPushdown() && !applySample; - List ranges = onPluginClassLoader(scanProvider, () -> scanProvider.planScan( - connectorSession, currentHandle, columns, remainingFilter, sourceLimit, - requiredPartitions, countPushdown)); + ConnectorScanRequest request = ConnectorScanRequest.builder(currentHandle, columns) + .filter(remainingFilter) + .limit(sourceLimit) + .requiredPartitions(requiredPartitions) + .countPushdown(countPushdown) + .build(); + List ranges = onPluginClassLoader(scanProvider, + () -> scanProvider.planScan(connectorSession, request)); List splits = new ArrayList<>(ranges.size()); for (ConnectorScanRange range : ranges) { @@ -1657,6 +1623,12 @@ public void startSplit(int numBackends) { pinRewriteFileScope(); final ConnectorTableHandle handle = currentHandle; final ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + // One request for the whole batched scan; each batch re-scopes it to its own partitions. No row + // limit and no COUNT(*) pushdown on this path (batch mode is entered before either applies), + // matching what the batched call passed before the request object existed. + final ConnectorScanRequest batchRequest = ConnectorScanRequest.builder(handle, columns) + .filter(remainingFilter) + .build(); final List allPartitions = new ArrayList<>(selectedPartitions.selectedPartitions.keySet()); final int batchSize = sessionVariable.getNumPartitionsInBatchMode(); @@ -1678,7 +1650,7 @@ public void startSplit(int numBackends) { try { List ranges = onPluginClassLoader(scanProvider, () -> scanProvider.planScanForPartitionBatch( - connectorSession, handle, columns, remainingFilter, -1L, batch)); + connectorSession, batchRequest, batch)); List batchSplits = new ArrayList<>(ranges.size()); for (ConnectorScanRange range : ranges) { batchSplits.add(new PluginDrivenSplit(range)); @@ -1800,39 +1772,48 @@ protected void setScanParams(TFileRangeDesc rangeDesc, Split split) { @Override - protected Optional getSerializedTable() { + protected boolean isFileCacheAdmissionApplicable() { + // Answered by the connector that will actually serve this scan — for a heterogeneous catalog that is + // the sibling picked from the table handle, not the catalog's own type. A connector that has not + // resolved (system tables, an unavailable plugin) stays out of the governance, as before. ConnectorScanPlanProvider scanProvider = resolveScanProvider(); - if (scanProvider != null) { - Map props = getOrLoadScanNodeProperties(); - String serialized = onPluginClassLoader(scanProvider, () -> scanProvider.getSerializedTable(props)); - if (serialized != null) { - return Optional.of(serialized); - } - } - return Optional.empty(); + return scanProvider != null && onPluginClassLoader(scanProvider, scanProvider::supportsFileCache); } @Override public void createScanRangeLocations() throws UserException { super.createScanRangeLocations(); - // Delegate scan-level Thrift params to the connector SPI ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + // Prune BEFORE delegating: "the connector took ALL the filtering" is only true of the pruned set, and + // the connector is told that below so it can decide whether limiting rows at the source is safe. + // ATTN this reordering is inert, but NOT for the reason it looks like: the property cache is already + // warm here, because super.createScanRangeLocations() asks for the file format type first, which loads + // it. So pruning cannot pull the connector call, the MVCC-snapshot/rewrite-scope/topn pins, or a + // wrapped load failure any earlier than they already happened. Keep this call below + // resolveScanProvider() above: the provider memo is keyed on handle identity, and the pins can replace + // the handle. + pruneConjunctsFromNodeProperties(); if (scanProvider != null) { - Map props = getOrLoadScanNodeProperties(); + // A copy, like the EXPLAIN path: the synthetic facts must not pollute the cached property map. + Map scanProps = new HashMap<>(getOrLoadScanNodeProperties()); + injectPushdownFacts(scanProps, limit, conjuncts.isEmpty()); onPluginClassLoader(scanProvider, () -> { - scanProvider.populateScanLevelParams(params, props); + scanProvider.populateScanLevelParams(params, scanProps); return null; }); } - pruneConjunctsFromNodeProperties(); + } - // Push down limit to ES via terminate_after optimization. - // When all predicates are pushed to ES (conjuncts empty) and limit fits in one batch, - // ES can use terminate_after to stop scanning early instead of scrolling all results. - if (limit > 0 && limit <= sessionVariable.batchSize && conjuncts.isEmpty() - && params.isSetEsProperties()) { - params.getEsProperties().put("limit", String.valueOf(limit)); - } + /** + * Writes the two engine-only facts a connector needs to decide whether it may ask its source to stop + * early: the pushed-down limit, and whether the engine has any filtering left to do after the scan. + * Both paths (EXPLAIN and thrift) inject them, so a connector reports the same thing it does. + * + *

      Package-visible and static so the mapping is unit-testable without a planner.

      + */ + static void injectPushdownFacts(Map props, long pushdownLimit, boolean allConjunctsPushed) { + props.put(ScanNodePropertyKeys.SYNTHETIC_PUSHDOWN_LIMIT, String.valueOf(pushdownLimit)); + props.put(ScanNodePropertyKeys.SYNTHETIC_ALL_CONJUNCTS_PUSHED, String.valueOf(allConjunctsPushed)); } @@ -1840,9 +1821,12 @@ public void createScanRangeLocations() throws UserException { * Prunes pushed-down conjuncts using the structured result from * {@link ConnectorScanPlanProvider#getScanNodePropertiesResult()}. * - *

      Only conjuncts whose indices are in the not-pushed set are retained. - * If the connector has no not-pushed tracking (empty set), all conjuncts - * are considered pushed and cleared.

      + *

      Only conjuncts whose indices are in the not-pushed set are retained. A connector that supplies NO + * tracking at all (the single-argument {@code ScanNodePropertiesResult} constructor, which is also the + * SPI default) prunes NOTHING — every conjunct stays and BE re-evaluates it. That is not the same thing + * as reporting an empty not-pushed set, which is the claim "all of them were pushed, prune them all". + * Keep the two apart: conflating them would silently drop predicates for the seven connectors that do not + * implement tracking.

      */ private void pruneConjunctsFromNodeProperties() { if (conjuncts == null || conjuncts.isEmpty()) { @@ -1929,7 +1913,7 @@ private ScanNodePropertiesResult getOrLoadPropertiesResult() { connectorSession, currentHandle, columns, filter)); } if (cachedPropertiesResult == null) { - cachedPropertiesResult = new ScanNodePropertiesResult(Collections.emptyMap()); + cachedPropertiesResult = ScanNodePropertiesResult.of(Collections.emptyMap()); } } return cachedPropertiesResult; diff --git a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemFactory.java b/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemFactory.java index cc236a9fabc1b3..af849320ae0cfc 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/fs/FileSystemFactory.java @@ -108,7 +108,7 @@ public static org.apache.doris.filesystem.FileSystem getFileSystem(MapMirrors {@link #getFileSystem(Map)}'s dual path: when {@link #initPluginManager} has run * (production), delegates to the live {@link FileSystemPluginManager#bindAll} so the runtime diff --git a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ESCatalogAction.java b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ESCatalogAction.java index 48f6947bcda402..1fc2bfcb75ada0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ESCatalogAction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ESCatalogAction.java @@ -20,6 +20,7 @@ import org.apache.doris.catalog.Env; import org.apache.doris.common.Config; import org.apache.doris.common.util.JsonUtil; +import org.apache.doris.connector.api.rest.ConnectorRestPassthrough; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.httpv2.entity.ResponseEntityBuilder; @@ -50,7 +51,7 @@ public class ESCatalogAction extends RestBaseController { private static final String TABLE = "table"; private Object handleRequest(HttpServletRequest request, HttpServletResponse response, - BiFunction action) { + BiFunction action) { if (Config.enable_all_http_auth) { executeCheckPassword(request, response); } @@ -68,8 +69,15 @@ private Object handleRequest(HttpServletRequest request, HttpServletResponse res || !"es".equals(((PluginDrivenExternalCatalog) catalog).getType())) { return ResponseEntityBuilder.badRequest("unknown ES Catalog: " + catalogName); } - ((PluginDrivenExternalCatalog) catalog).makeSureInitialized(); - String result = action.apply(catalog, tableName); + PluginDrivenExternalCatalog esCatalog = (PluginDrivenExternalCatalog) catalog; + esCatalog.makeSureInitialized(); + // These endpoints emulate the ES HTTP API, so they need the connector to forward the request. A + // connector that cannot is not usable here; probe rather than call and catch. + ConnectorRestPassthrough rest = esCatalog.getConnector().getRestPassthrough(); + if (rest == null) { + return ResponseEntityBuilder.badRequest("unknown ES Catalog: " + catalogName); + } + String result = action.apply(rest, tableName); ObjectNode jsonResult = JsonUtil.parseObject(result); resultMap.put("catalog", catalogName); @@ -81,10 +89,8 @@ private Object handleRequest(HttpServletRequest request, HttpServletResponse res @RequestMapping(path = "/get_mapping", method = RequestMethod.GET) public Object getMapping(HttpServletRequest request, HttpServletResponse response) { - return handleRequest(request, response, (catalog, tableName) -> { - return ((PluginDrivenExternalCatalog) catalog).getConnector() - .executeRestRequest(tableName + "/_mapping", null); - }); + return handleRequest(request, response, + (rest, tableName) -> rest.executeRestRequest(tableName + "/_mapping", null)); } @RequestMapping(path = "/search", method = RequestMethod.POST) @@ -95,10 +101,8 @@ public Object search(HttpServletRequest request, HttpServletResponse response) { } catch (IOException e) { return ResponseEntityBuilder.okWithCommonError(e.getMessage()); } - return handleRequest(request, response, (catalog, tableName) -> { - return ((PluginDrivenExternalCatalog) catalog).getConnector() - .executeRestRequest(tableName + "/_search", body); - }); + return handleRequest(request, response, + (rest, tableName) -> rest.executeRestRequest(tableName + "/_search", body)); } private String getRequestBody(HttpServletRequest request) throws IOException { 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 066707496f01dd..9e937abd227def 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 @@ -45,12 +45,12 @@ 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.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.WriteOperation; import org.apache.doris.connector.api.write.ConnectorWritePlanProvider; import org.apache.doris.connector.api.write.ConnectorWriteSortColumn; import org.apache.doris.datasource.ExternalTable; +import org.apache.doris.datasource.connector.converter.ConnectorColumnConverter; import org.apache.doris.datasource.doris.RemoteDorisExternalTable; import org.apache.doris.datasource.doris.RemoteOlapTable; import org.apache.doris.datasource.doris.source.RemoteDorisScanNode; @@ -237,6 +237,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; @@ -612,9 +613,12 @@ private PluginDrivenTableSink buildPluginRowLevelDmlSink( ConnectorSession connSession = catalog.buildConnectorSession(); ConnectorMetadata metadata = PluginDrivenMetadata.get(connSession, connector); + // Convert the WHOLE type, not just its primitive tag: a row-level DML always carries the hidden + // __DORIS_ICEBERG_ROWID_COL__ STRUCT, and the target may hold ARRAY/MAP/STRUCT data columns. + // Naming only the tag would drop the children and yield a childless (invalid) complex type. List connectorColumns = sink.getCols().stream() .map(col -> new ConnectorColumn(col.getName(), - ConnectorType.of(col.getType().getPrimitiveType().toString()), + ConnectorColumnConverter.toConnectorType(col.getType()), null, col.isAllowNull(), null)) .collect(java.util.stream.Collectors.toList()); @@ -627,13 +631,17 @@ private PluginDrivenTableSink buildPluginRowLevelDmlSink( "Table not found: " + targetTable.getRemoteDbName() + "." + targetTable.getRemoteName() + " in catalog " + catalog.getName())); - Set writeOps = connector.supportedWriteOperations(providerTableHandle); + // The provider both admits the operation and plans the sink, so resolve it once: several connectors + // build a fresh provider per call and iceberg's construction reaches the live remote catalog. + ConnectorWritePlanProvider writePlanProvider = connector.getWritePlanProvider(providerTableHandle); + Set writeOps = writePlanProvider == null + ? EnumSet.noneOf(WriteOperation.class) + : writePlanProvider.supportedOperations(); if (!(writeOps.contains(WriteOperation.DELETE) || writeOps.contains(WriteOperation.MERGE))) { throw new AnalysisException( "Connector '" + catalog.getName() + "' (type: " + catalog.getType() + ") does not support row-level DML operations"); } - ConnectorWritePlanProvider writePlanProvider = connector.getWritePlanProvider(providerTableHandle); providerTableHandle = PluginDrivenScanNode.applyMvccSnapshotPin( metadata, connSession, providerTableHandle, MvccUtil.getSnapshotFromContext(targetTable)); @@ -660,10 +668,12 @@ public PlanFragment visitPhysicalConnectorTableSink( ConnectorSession connSession = catalog.buildConnectorSession(); ConnectorMetadata metadata = PluginDrivenMetadata.get(connSession, connector); - // Convert sink columns to connector columns for INSERT SQL generation + // Convert sink columns to connector columns for INSERT SQL generation. The whole type is + // converted (see the row-level DML arm): a bare primitive tag drops an ARRAY/MAP/STRUCT + // column's children and yields a childless, invalid complex type. List connectorColumns = connectorTableSink.getCols().stream() .map(col -> new ConnectorColumn(col.getName(), - ConnectorType.of(col.getType().getPrimitiveType().toString()), + ConnectorColumnConverter.toConnectorType(col.getType()), null, col.isAllowNull(), null)) .collect(java.util.stream.Collectors.toList()); @@ -679,12 +689,14 @@ public PlanFragment visitPhysicalConnectorTableSink( "Table not found: " + targetTable.getRemoteDbName() + "." + targetTable.getRemoteName() + " in catalog " + catalog.getName())); - if (!connector.supportedWriteOperations(providerTableHandle).contains(WriteOperation.INSERT)) { + // Resolve the provider once: it both admits INSERT and plans the sink (see the row-level DML arm). + ConnectorWritePlanProvider writePlanProvider = connector.getWritePlanProvider(providerTableHandle); + if (writePlanProvider == null + || !writePlanProvider.supportedOperations().contains(WriteOperation.INSERT)) { throw new AnalysisException( "Connector '" + catalog.getName() + "' (type: " + catalog.getType() + ") does not support INSERT operations"); } - ConnectorWritePlanProvider writePlanProvider = connector.getWritePlanProvider(providerTableHandle); // Thread the statement's MVCC snapshot pin onto the WRITE handle, reusing the exact scan-side pin // logic so a DML's write anchors at the SAME snapshot its scan read (the pin is keyed by diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java index 31e95f1ad2b683..46a6f7f6a63ab5 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java @@ -275,7 +275,6 @@ import org.apache.doris.nereids.DorisParser.ModifyColumnClauseContext; import org.apache.doris.nereids.DorisParser.ModifyColumnCommentClauseContext; import org.apache.doris.nereids.DorisParser.ModifyDistributionClauseContext; -import org.apache.doris.nereids.DorisParser.ModifyEngineClauseContext; import org.apache.doris.nereids.DorisParser.ModifyPartitionClauseContext; import org.apache.doris.nereids.DorisParser.ModifyTableCommentClauseContext; import org.apache.doris.nereids.DorisParser.MultiStatementsContext; @@ -990,7 +989,6 @@ import org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnCommentOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyDistributionOp; -import org.apache.doris.nereids.trees.plans.commands.info.ModifyEngineOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyNodeHostNameOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyNodeHostNameOp.ModifyOpType; import org.apache.doris.nereids.trees.plans.commands.info.ModifyPartitionOp; @@ -6550,15 +6548,6 @@ public AlterTableOp visitModifyColumnCommentClause(ModifyColumnCommentClauseCont return new ModifyColumnCommentOp(columnPath, comment); } - @Override - public AlterTableOp visitModifyEngineClause(ModifyEngineClauseContext ctx) { - String engineName = ctx.name.getText(); - Map properties = ctx.properties != null - ? Maps.newHashMap(visitPropertyClause(ctx.properties)) - : Maps.newHashMap(); - return new ModifyEngineOp(engineName, properties); - } - @Override public AlterTableOp visitAlterMultiPartitionClause(AlterMultiPartitionClauseContext ctx) { boolean isTempPartition = ctx.TEMPORARY() != null; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java index 286652e80f9862..d1667546adcc79 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterTableCommand.java @@ -56,7 +56,6 @@ import org.apache.doris.nereids.trees.plans.commands.info.EnableFeatureOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnCommentOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyColumnOp; -import org.apache.doris.nereids.trees.plans.commands.info.ModifyEngineOp; import org.apache.doris.nereids.trees.plans.commands.info.ModifyTablePropertiesOp; import org.apache.doris.nereids.trees.plans.commands.info.RenameColumnOp; import org.apache.doris.nereids.trees.plans.commands.info.RenameTableOp; @@ -385,7 +384,6 @@ private void checkExternalTableOperationAllow(TableIf table) throws UserExceptio && table instanceof PluginDrivenExternalTable && ((PluginDrivenExternalTable) table).supportsNestedColumnSchemaChange()) || alterTableOp instanceof ReorderColumnsOp - || alterTableOp instanceof ModifyEngineOp || alterTableOp instanceof ModifyTablePropertiesOp || alterTableOp instanceof CreateOrReplaceBranchOp || alterTableOp instanceof CreateOrReplaceTagOp 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 28d6f0cf525739..56c565e4c01ce1 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 @@ -36,7 +36,6 @@ import org.apache.doris.common.proc.ProcResult; import org.apache.doris.common.proc.ProcService; import org.apache.doris.common.util.OrderByPair; -import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorCapability; import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorPartitionInfo; @@ -342,12 +341,9 @@ private ShowResultSet handleShowPluginDrivenTablePartitions() throws AnalysisExc * column headers and the row width never disagree. */ private boolean hasPartitionStatsCapability() { - if (!(catalog instanceof PluginDrivenExternalCatalog)) { - return false; - } - Connector connector = ((PluginDrivenExternalCatalog) catalog).getConnector(); - return connector != null - && connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_PARTITION_STATS); + return catalog instanceof PluginDrivenExternalCatalog + && ((PluginDrivenExternalCatalog) catalog) + .hasConnectorCapability(ConnectorCapability.SUPPORTS_PARTITION_STATS); } protected ShowResultSet handleShowPartitions(ConnectContext ctx, StmtExecutor executor) throws UserException { 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 6f7624795635bc..ac4ae615afdfce 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 @@ -20,6 +20,7 @@ import org.apache.doris.analysis.UserIdentity; import org.apache.doris.catalog.Env; import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPassthroughSqlOps; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; @@ -96,14 +97,18 @@ public void run() { throw new AnalysisException("user " + user + " has no privilege to execute stmt in catalog " + catalogName); } + // Passthrough SQL is an optional SPI interface, not a capability flag: a connector that implements + // ConnectorPassthroughSqlOps offers it, one that does not is refused here. if (catalogIf instanceof PluginDrivenExternalCatalog) { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalogIf; ConnectorSession session = pluginCatalog.buildConnectorSession(); ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); - metadata.executeStmt(session, stmt); - } else { - throw new AnalysisException("executeStmt not supported for catalog type: " - + catalogIf.getType()); + if (metadata instanceof ConnectorPassthroughSqlOps) { + ((ConnectorPassthroughSqlOps) metadata).executeStmt(session, stmt); + return; + } } + throw new AnalysisException("executeStmt not supported for catalog type: " + + catalogIf.getType()); } } 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 ac70851a7b46f2..438cca1c4c5665 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 @@ -75,8 +75,8 @@ * unchecked {@link DorisConnectorException}; this adapter converts it to a {@code UserException} so * {@code ExecuteActionCommand.run()} re-wraps it with the legacy {@code "Failed to execute action:"} prefix.

      * - *

      Live for the flipped SPI catalogs; per-handle for a gateway. Iceberg and paimon are already in - * {@code SPI_READY_TYPES}, so their tables are {@code PluginDrivenExternalTable}s and {@code ALTER TABLE EXECUTE} + *

      Live for the flipped SPI catalogs; per-handle for a gateway. Iceberg and paimon are served by + * their connector plugins, so their tables are {@code PluginDrivenExternalTable}s and {@code ALTER TABLE EXECUTE} * on them routes through this adapter today. Procedure ops are selected {@link Connector#getProcedureOps( * org.apache.doris.connector.api.handle.ConnectorTableHandle) per-handle}: a single-format connector just returns * its connector-level ops, but a flipped {@code hms} gateway 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 0d948683cc5720..1f2454f2f864e4 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 @@ -19,10 +19,8 @@ import org.apache.doris.catalog.Env; import org.apache.doris.common.UserException; -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.ConnectorType; import org.apache.doris.connector.api.DorisConnectorException; import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.ConnectorTransaction; @@ -30,6 +28,7 @@ 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.procedure.ConnectorRewriteStatistics; import org.apache.doris.connector.api.pushdown.ConnectorPredicate; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; @@ -38,7 +37,6 @@ import org.apache.doris.scheduler.executor.TransientTaskExecutor; import org.apache.doris.transaction.PluginDrivenTransactionManager; -import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -53,15 +51,15 @@ /** * Engine-neutral driver for a distributed {@code rewrite_data_files} (compaction), the post-flip - * counterpart of the legacy {@code RewriteDataFileExecutor} + {@code IcebergRewriteDataFilesAction}. It - * orchestrates the read/write distribution; the connector owns the file-selection / bin-pack / commit - * decisions behind neutral SPIs (no {@code instanceof Iceberg}, no iceberg types). + * counterpart of the legacy per-source rewrite executor. It orchestrates the read/write distribution; the + * connector owns the file-selection / bin-pack / commit decisions AND the shape of the result row, behind + * neutral SPIs (no {@code instanceof} on a connector type, no source-specific types). * *

      Flow: (0) ask the connector to plan N bin-packed groups ({@link ConnectorProcedureOps#planRewrite}); (1) * open ONE shared connector transaction; (2) run one {@code INSERT-SELECT} per group concurrently, each * scoped to its files and bound to the shared transaction; (3) register the union of source files to remove * (AFTER the groups began the transaction so the table + OCC snapshot are loaded); (4) commit once; (5) read - * the added-files count post-commit and emit the four-column result row.

      + * the added-files count post-commit and ask the connector to render the result row.

      * *

      R6 scope. No-WHERE rewrite only (WHERE lowering is a later step). Output file SIZING — the * per-group {@code target-file-size}/parallelism that the legacy task threaded via an iceberg session var — @@ -120,8 +118,11 @@ public ConnectorProcedureResult run() throws UserException { throw new UserException(e.getMessage(), e); } if (groups == null || groups.isEmpty()) { - // Nothing to rewrite: skip the transaction entirely and return the all-zero row (legacy parity). - return buildResult(0, 0, 0, 0); + // Nothing to rewrite: skip the transaction entirely and let the connector render its all-zero + // row (legacy parity). There is no transaction on this path, which is why buildRewriteResult + // must render locally. + return procedureOps.buildRewriteResult(procedureName, + new ConnectorRewriteStatistics(0, 0, 0L, 0)); } // STEP 1: open ONE shared connector transaction for all groups. @@ -179,8 +180,9 @@ public ConnectorProcedureResult run() throws UserException { long rewrittenBytesCount = groups.stream().mapToLong(ConnectorRewriteGroup::getTotalSizeBytes).sum(); int removedDeleteFilesCount = groups.stream().mapToInt(ConnectorRewriteGroup::getDeleteFileCount).sum(); - return buildResult(rewrittenDataFilesCount, addedDataFilesCount, rewrittenBytesCount, - removedDeleteFilesCount); + // The connector names and types its own result columns; the engine only reports what it ran. + return procedureOps.buildRewriteResult(procedureName, new ConnectorRewriteStatistics( + rewrittenDataFilesCount, addedDataFilesCount, rewrittenBytesCount, removedDeleteFilesCount)); } /** @@ -242,30 +244,9 @@ public void onTaskFailed(Long taskId, Exception error) { } } - private ConnectorProcedureResult buildResult(int rewrittenDataFilesCount, int addedDataFilesCount, - long rewrittenBytesCount, int removedDeleteFilesCount) { - // Four-column schema in the exact legacy order/types (IcebergRewriteDataFilesAction.getResultSchema); - // rewritten_bytes_count is INT for byte-parity with legacy (a latent quirk kept on purpose). - List schema = ImmutableList.of( - new ConnectorColumn("rewritten_data_files_count", ConnectorType.of("INT"), - "Number of data which were re-written by this command", false, null), - new ConnectorColumn("added_data_files_count", ConnectorType.of("INT"), - "Number of new data files which were written by this command", false, null), - new ConnectorColumn("rewritten_bytes_count", ConnectorType.of("INT"), - "Number of bytes which were written by this command", false, null), - new ConnectorColumn("removed_delete_files_count", ConnectorType.of("BIGINT"), - "Number of delete files removed by this command", false, null)); - List row = ImmutableList.of( - String.valueOf(rewrittenDataFilesCount), - String.valueOf(addedDataFilesCount), - String.valueOf(rewrittenBytesCount), - String.valueOf(removedDeleteFilesCount)); - return new ConnectorProcedureResult(schema, ImmutableList.of(row)); - } - /** - * Collects concurrent group-task completions and cancels the rest on the first failure (copied from the - * legacy {@code RewriteDataFileExecutor.RewriteResultCollector}). + * Collects concurrent group-task completions and cancels the rest on the first failure (ported verbatim + * from the result collector of the pre-SPI rewrite executor, which no longer exists in the tree). */ private static class RewriteResultCollector { private final int expectedTasks; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java index c8fde96907d53a..c0f8b59067cf62 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java @@ -141,6 +141,12 @@ public class CreateTableInfo { private List indexes; private List ctasColumns; private String engineName; + /** + * Whether the target catalog is the internal one. This is the only engine-shaped question analysis still + * asks: the internal catalog creates olap tables, whose validation lives here, while every other catalog + * validates its own tables inside its connector. Set by {@link #resolveTargetCatalog()} before any use. + */ + private boolean targetIsInternalCatalog; private KeysType keysType; private List rollups; private Map extProperties; @@ -372,27 +378,6 @@ public List getTableNameParts() { return ImmutableList.of(tableName); } - private void checkEngineWithCatalog() { - if (engineName.equals(ENGINE_OLAP)) { - if (!ctlName.equals(InternalCatalog.INTERNAL_CATALOG_NAME)) { - throw new AnalysisException("Cannot create olap table out of internal catalog." - + " Make sure 'engine' type is specified when use the catalog: " + ctlName); - } - } - if (Strings.isNullOrEmpty(ctlName)) { - return; - } - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName); - if (catalog instanceof PluginDrivenExternalCatalog) { - // After the SPI cutover a max_compute / paimon catalog is a PluginDrivenExternalCatalog; mirror - // the legacy per-type consistency check, keyed on the connector type. - String pluginEngine = pluginCatalogTypeToEngine((PluginDrivenExternalCatalog) catalog); - if (pluginEngine != null && !engineName.equals(pluginEngine)) { - throw new AnalysisException("This catalog can only use `" + pluginEngine + "` engine."); - } - } - } - /** * analyze create table info */ @@ -410,8 +395,7 @@ public void validate(ConnectContext ctx) { ctlName = InternalCatalog.INTERNAL_CATALOG_NAME; } } - paddingEngineName(ctlName, ctx); - checkEngineName(); + resolveTargetCatalog(); // not allow auto bucket with auto list partition if (partitionTableInfo != null @@ -424,7 +408,7 @@ public void validate(ConnectContext ctx) { properties = Maps.newHashMap(); } - if (engineName.equalsIgnoreCase(ENGINE_OLAP)) { + if (targetIsInternalCatalog) { if (distribution == null) { distribution = new DistributionDescriptor(false, true, FeConstants.default_bucket_num, null); } @@ -438,8 +422,6 @@ public void validate(ConnectContext ctx) { throw new AnalysisException(e.getMessage(), e); } - checkEngineWithCatalog(); - // analyze table name if (Strings.isNullOrEmpty(dbName)) { dbName = ctx.getDatabase(); @@ -501,7 +483,7 @@ public void validate(ConnectContext ctx) { } }); - if (engineName.equalsIgnoreCase(ENGINE_OLAP)) { + if (targetIsInternalCatalog) { boolean enableDuplicateWithoutKeysByDefault = false; properties = PropertyAnalyzer.getInstance().rewriteOlapProperties(ctlName, dbName, properties); @@ -772,14 +754,14 @@ public void validate(ConnectContext ctx) { rollup.validate(); } } else { - // mysql, broker and hive do not need key desc + // An external table has no Doris key model to declare. if (keysType != null) { throw new AnalysisException( - "Create " + engineName + " table should not contain keys desc"); + "Create table in catalog '" + ctlName + "' should not contain keys desc"); } if (!rollups.isEmpty()) { - throw new AnalysisException(engineName + " catalog doesn't support rollup tables."); + throw new AnalysisException("Catalog '" + ctlName + "' doesn't support rollup tables."); } // DISTRIBUTE BY / write sort order / NOT NULL columns / hive external partition rules are per-source @@ -790,12 +772,15 @@ public void validate(ConnectContext ctx) { // every internal-catalog engine) is rejected here. if (sortOrderFields != null && !sortOrderFields.isEmpty()) { CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName); + // hasConnectorCapability forces the catalog to initialize, which is acceptable here: the user + // asked for a clause only one connector supports, so a catalog that cannot even be reached + // owes them the initialization error rather than a clause-support answer. boolean supportsSortOrder = catalog instanceof PluginDrivenExternalCatalog - && ((PluginDrivenExternalCatalog) catalog).getConnector().getCapabilities() - .contains(ConnectorCapability.SUPPORTS_SORT_ORDER); + && ((PluginDrivenExternalCatalog) catalog) + .hasConnectorCapability(ConnectorCapability.SUPPORTS_SORT_ORDER); if (!supportsSortOrder) { throw new AnalysisException( - "Sort order (ORDER BY) is not supported for engine: " + engineName); + "Sort order (ORDER BY) is not supported by catalog '" + ctlName + "'."); } } @@ -803,21 +788,12 @@ public void validate(ConnectContext ctx) { columnDef.setIsKey(true); } } - // validate column - try { - if (!engineName.equals(ENGINE_ELASTICSEARCH) && columns.isEmpty()) { - ErrorReport.reportAnalysisException(ErrorCode.ERR_TABLE_MUST_HAVE_COLUMNS); - } - } catch (Exception e) { - throw new AnalysisException(e.getMessage(), e.getCause()); - } - final boolean finalEnableMergeOnWrite = isEnableMergeOnWrite; Set keysSet = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER); keysSet.addAll(keys); Set orderKeySet = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER); orderKeySet.addAll(sortOrderFields.stream().map(SortFieldInfo::getColumnName).collect(Collectors.toSet())); - columns.forEach(c -> c.validate(engineName.equals(ENGINE_OLAP), keysSet, orderKeySet, finalEnableMergeOnWrite, + columns.forEach(c -> c.validate(targetIsInternalCatalog, keysSet, orderKeySet, finalEnableMergeOnWrite, keysType)); // validate index @@ -832,7 +808,7 @@ public void validate(ConnectContext ctx) { for (IndexDefinition indexDef : indexes) { indexDef.validate(); - if (!engineName.equalsIgnoreCase(ENGINE_OLAP)) { + if (!targetIsInternalCatalog) { throw new AnalysisException( "index only support in olap engine at current version."); } @@ -870,7 +846,7 @@ public void validate(ConnectContext ctx) { generatedColumnCheck(ctx); analyzeEngine(); - if (engineName.equalsIgnoreCase(ENGINE_OLAP)) { + if (targetIsInternalCatalog) { Env env = Env.getCurrentEnv(); if (ctx != null && env != null && partitionTableInfo != null && !partitionTableInfo.getPartitionList().isEmpty()) { @@ -883,51 +859,52 @@ public void validate(ConnectContext ctx) { } } - private void paddingEngineName(String ctlName, ConnectContext ctx) { + /** + * Resolves the target catalog and settles everything the statement used to derive from the engine name. + * + *

      {@code ENGINE=} predates catalogs and is optional. The engine keeps no table of which name belongs to + * which data source: an explicitly written name is handed to the target catalog, which alone knows whether + * it is its own ({@link CatalogIf#validateCreateTableEngine}). An omitted clause is always legal.

      + * + *

      Only the internal catalog still needs a name downstream — {@code InternalCatalog.createTable} + * dispatches on it — so it keeps being padded with {@code olap}. An external target simply carries no + * engine name: nothing past analysis reads one (the connector request is built from columns, partitioning, + * bucketing and properties), which is why the engine can stop inventing one.

      + */ + private void resolveTargetCatalog() { Preconditions.checkArgument(!Strings.isNullOrEmpty(ctlName)); - if (Strings.isNullOrEmpty(engineName)) { - CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName); - if (catalog == null) { - throw new AnalysisException("Unknown catalog: " + ctlName); - } - - if (catalog instanceof InternalCatalog) { - engineName = ENGINE_OLAP; - } else if (catalog instanceof PluginDrivenExternalCatalog - && pluginCatalogTypeToEngine((PluginDrivenExternalCatalog) catalog) != null) { - // After the SPI cutover a max_compute / paimon catalog is a PluginDrivenExternalCatalog; pad - // the legacy engine so the no-ENGINE CREATE TABLE keeps working (mirrors the MC/Iceberg above). - engineName = pluginCatalogTypeToEngine((PluginDrivenExternalCatalog) catalog); - } else { - throw new AnalysisException("Current catalog does not support create table: " + ctlName); + CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName); + if (catalog == null) { + throw new AnalysisException("Unknown catalog: " + ctlName); + } + targetIsInternalCatalog = catalog.isInternalCatalog(); + + if (!Strings.isNullOrEmpty(engineName)) { + try { + catalog.validateCreateTableEngine(engineName); + } catch (org.apache.doris.common.AnalysisException e) { + // The catalog family throws the checked AnalysisException; analysis here reports the unchecked + // one. Only the type is adapted -- getDetailMessage() is the catalog's own wording without the + // "errCode = N, detailMessage = " envelope that family adds, so the user sees it verbatim. + throw new AnalysisException(e.getDetailMessage(), e); } + } else if (targetIsInternalCatalog) { + engineName = ENGINE_OLAP; } - } - /** - * Maps a PluginDriven (SPI) catalog's type to the legacy engine name used for DDL engine-padding - * and catalog-engine consistency. Keyed on {@link PluginDrivenExternalCatalog#getType()} (the - * CatalogFactory key, e.g. "max_compute"), mirroring - * {@code PluginDrivenExternalTable.getEngine()/getEngineTableTypeName()} — the two switches must - * stay in sync if SPI_READY_TYPES gains a CREATE-TABLE-capable full-adopter. Returns {@code null} - * for SPI types that do not support CREATE TABLE (jdbc/es/trino-connector) so callers preserve - * their existing (legacy-equivalent) behavior for those types. - */ - private static String pluginCatalogTypeToEngine(PluginDrivenExternalCatalog catalog) { - switch (catalog.getType()) { - case "max_compute": - return ENGINE_MAXCOMPUTE; - case "paimon": - return ENGINE_PAIMON; - case "iceberg": - return ENGINE_ICEBERG; - case "hms": - // A flipped HMS external catalog uses the hive engine for CREATE TABLE (legacy hms - // catalogs always create hive-engine tables); the user-visible DISPLAY engine "hms" - // is a separate concern handled by PluginDrivenExternalTable.getEngine(). - return ENGINE_HIVE; - default: - return null; + // EXTERNAL is legacy syntax that only ever meant "not an olap table". It used to be forced on by the + // engine-name whitelist; derive it from the target instead, and keep rejecting it where it contradicts + // the target. It is not cosmetic: it relaxes partition validation for tables Doris does not own. + if (isExternal && targetIsInternalCatalog) { + throw new AnalysisException("Do not support external table with engine name = olap"); + } + isExternal = !targetIsInternalCatalog; + + if (isTemp && !targetIsInternalCatalog) { + throw new AnalysisException("Do not support temporary table in catalog: " + ctlName); + } + if (isTemp && !rollups.isEmpty()) { + throw new AnalysisException("Do not support temporary table with rollup "); } } @@ -937,54 +914,17 @@ private static String pluginCatalogTypeToEngine(PluginDrivenExternalCatalog cata public void validateCreateTableAsSelect(List qualifierTableName, List columns, ConnectContext ctx) { String catalogName = qualifierTableName.get(0); - paddingEngineName(catalogName, ctx); + this.ctlName = catalogName; + resolveTargetCatalog(); this.columns = Utils.copyRequiredMutableList(columns); // bucket num is hard coded 10 to be consistent with legacy planner - if (engineName.equals(ENGINE_OLAP) && this.distribution == null) { - if (!catalogName.equals(InternalCatalog.INTERNAL_CATALOG_NAME)) { - throw new AnalysisException("Cannot create olap table out of internal catalog." - + " Make sure 'engine' type is specified when use the catalog: " + catalogName); - } + if (targetIsInternalCatalog && this.distribution == null) { this.distribution = new DistributionDescriptor(true, false, 10, Lists.newArrayList(columns.get(0).getName())); } validate(ctx); } - private void checkEngineName() { - if (engineName.equals(ENGINE_MYSQL) || engineName.equals(ENGINE_ODBC) || engineName.equals(ENGINE_BROKER) - || engineName.equals(ENGINE_ELASTICSEARCH) || engineName.equals(ENGINE_HIVE) - || engineName.equals(ENGINE_ICEBERG) || engineName.equals(ENGINE_JDBC) - || engineName.equals(ENGINE_PAIMON) || engineName.equals(ENGINE_MAXCOMPUTE)) { - if (!isExternal) { - // this is for compatibility - isExternal = true; - } - } else { - if (isExternal) { - throw new AnalysisException( - "Do not support external table with engine name = olap"); - } else if (!engineName.equals(ENGINE_OLAP)) { - throw new AnalysisException( - "Do not support table with engine name = " + engineName); - } - } - - if (isTemp && !engineName.equals(ENGINE_OLAP)) { - throw new AnalysisException("Do not support temporary table with engine name = " + engineName); - } - if (isTemp && !rollups.isEmpty()) { - throw new AnalysisException("Do not support temporary table with rollup "); - } - - if ((engineName.equals(ENGINE_ODBC) - || engineName.equals(ENGINE_MYSQL) || engineName.equals(ENGINE_BROKER))) { - throw new AnalysisException("odbc, mysql and broker table is no longer supported." - + " For odbc and mysql external table, use jdbc table or jdbc catalog instead." - + " For broker table, use table valued function instead."); - } - } - /** * if auto bucket auto bucket enable, rewrite distribution bucket num && * set properties[PropertyAnalyzer.PROPERTIES_AUTO_BUCKET] = "true" @@ -1127,23 +1067,6 @@ public void analyzeEngine() { this.distributionDesc = distribution != null ? distribution.translateToCatalogStyle() : null; - if (engineName.equals(ENGINE_ELASTICSEARCH)) { - if (distributionDesc != null) { - throw new AnalysisException("could not support distribution clause"); - } - } else if (!engineName.equals(ENGINE_OLAP)) { - if (!engineName.equals(ENGINE_HIVE) && !engineName.equals(ENGINE_MAXCOMPUTE) - && distributionDesc != null) { - throw new AnalysisException("Create " + engineName - + " table should not contain distribution desc"); - } - if (!engineName.equals(ENGINE_HIVE) && !engineName.equals(ENGINE_ICEBERG) - && !engineName.equals(ENGINE_PAIMON) && !engineName.equals(ENGINE_MAXCOMPUTE) - && partitionDesc != null) { - throw new AnalysisException("Create " + engineName - + " table should not contain partition desc"); - } - } } public void setIsExternal(boolean isExternal) { @@ -1157,7 +1080,7 @@ private void generatedColumnCommonCheck() { throw new AnalysisException("The generated columns can be key columns, " + "or value columns of replace and replace_if_not_null aggregation type."); } - if (column.getGeneratedColumnDesc().isPresent() && !engineName.equalsIgnoreCase("olap")) { + if (column.getGeneratedColumnDesc().isPresent() && !targetIsInternalCatalog) { throw new AnalysisException("Tables can only have generated columns if the olap engine is used"); } } @@ -1538,7 +1461,9 @@ public String toSql() { } } sb.append("\n)"); - sb.append(" ENGINE = ").append(engineName.toLowerCase()); + if (!Strings.isNullOrEmpty(engineName)) { + sb.append(" ENGINE = ").append(engineName.toLowerCase()); + } if (keys != null) { sb.append("\n").append(getKeysDesc().toSql()); @@ -1579,7 +1504,7 @@ public String toSql() { } if (extProperties != null && !extProperties.isEmpty()) { - sb.append("\n").append(engineName.toUpperCase()).append(" PROPERTIES ("); + sb.append("\n").append(Strings.nullToEmpty(engineName).toUpperCase()).append(" PROPERTIES ("); sb.append(new DatasourcePrintableMap<>(extProperties, " = ", true, true, true)); sb.append(")"); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyEngineOp.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyEngineOp.java deleted file mode 100644 index a64118285f9426..00000000000000 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/ModifyEngineOp.java +++ /dev/null @@ -1,77 +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.nereids.trees.plans.commands.info; - -import org.apache.doris.alter.AlterOpType; -import org.apache.doris.common.AnalysisException; -import org.apache.doris.common.UserException; -import org.apache.doris.qe.ConnectContext; - -import java.util.Map; - -/** - * ModifyEngineOp - */ -public class ModifyEngineOp extends AlterTableOp { - private String engine; - private Map properties; - - public ModifyEngineOp(String engine, Map properties) { - super(AlterOpType.MODIFY_ENGINE); - this.engine = engine; - this.properties = properties; - } - - public String getEngine() { - return engine; - } - - @Override - public Map getProperties() { - return this.properties; - } - - @Override - public void validate(ConnectContext ctx) throws UserException { - throw new AnalysisException( - "Modify engine from MySQL to ODBC is no longer supported. " - + "ODBC tables have been deprecated. Please use JDBC Catalog instead."); - } - - @Override - public boolean allowOpMTMV() { - return false; - } - - @Override - public boolean needChangeMTMVState() { - return false; - } - - @Override - public String toSql() { - StringBuilder sb = new StringBuilder(); - sb.append("MODIFY ENGINE TO ").append(engine); - return sb.toString(); - } - - @Override - public String toString() { - return toSql(); - } -} diff --git a/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java b/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java index 41d2c4f3dfcd40..9730d1d571e10d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java +++ b/fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java @@ -2414,10 +2414,6 @@ public void logDropRoleMapping(DropRoleMappingOperationLog log) { logEdit(OperationType.OP_DROP_ROLE_MAPPING, log); } - public void logModifyTableEngine(ModifyTableEngineOperationLog log) { - logEdit(OperationType.OP_MODIFY_TABLE_ENGINE, log); - } - public void logCreatePolicy(Policy policy) { logEdit(OperationType.OP_CREATE_POLICY, policy); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java index 9730925167a755..9fb30d35fb4c24 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PluginDrivenTableSink.java @@ -231,7 +231,7 @@ public boolean isOverwrite() { } @Override - public Map getWriteContext() { + public Map getStaticPartitionSpec() { return writeContext; } 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 ccd318e9a11520..bb9eea12e5e4aa 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 @@ -60,13 +60,13 @@ import org.apache.doris.connector.api.ConnectorMetadata; import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.scan.ConnectorPartitionValues; import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.CatalogMgr; import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalMetaCacheMgr; import org.apache.doris.datasource.ExternalTable; import org.apache.doris.datasource.InternalCatalog; -import org.apache.doris.datasource.TablePartitionValues; import org.apache.doris.datasource.metacache.MetaCacheEntryStats; import org.apache.doris.datasource.mvcc.MvccSnapshot; import org.apache.doris.datasource.mvcc.MvccUtil; @@ -2067,7 +2067,7 @@ private static TFetchSchemaTableDataResult partitionValuesMetadataResult(TMetada // A flipped hms table (and paimon/iceberg) is a PluginDrivenExternalTable, not an HMSExternalTable; the // partition values come from the connector's listPartitions via the generic SPI, then feed the same row - // builder as the HMS path (identical typed-TCell rendering, including HIVE_DEFAULT_PARTITION -> NULL). + // builder as the HMS path (identical typed-TCell rendering, including the canonical NULL partition name -> NULL). private static List partitionValuesMetadataResultForPluginTable(PluginDrivenExternalTable tbl, List colNames) throws AnalysisException { Optional snapshot = MvccUtil.getSnapshotFromContext(tbl); @@ -2103,7 +2103,7 @@ private static List partitionValuesRows(List partitionCols, ListIt was called {@code JdbcQueryTableValueFunction} because jdbc is the only connector that declares the + * passthrough capability today, but nothing here is jdbc-specific — {@link QueryTableValueFunction} routes + * EVERY catalog declaring that capability to this one class, and it reaches the connector only through the + * SPI. A second connector declaring it needs no new class and no change here.

      + */ +public class PluginDrivenQueryTableValueFunction extends QueryTableValueFunction { + public static final Logger LOG = LogManager.getLogger(PluginDrivenQueryTableValueFunction.class); - public JdbcQueryTableValueFunction(Map params) throws AnalysisException { + public PluginDrivenQueryTableValueFunction(Map params) throws AnalysisException { super(params); } @@ -51,7 +61,9 @@ public List getTableColumns() throws AnalysisException { PluginDrivenExternalCatalog pluginCatalog = (PluginDrivenExternalCatalog) catalogIf; ConnectorSession session = pluginCatalog.buildConnectorSession(); ConnectorMetadata metadata = PluginDrivenMetadata.get(session, pluginCatalog.getConnector()); - ConnectorTableSchema schema = metadata.getColumnsFromQuery(session, query); + // The factory already refused a catalog whose metadata does not implement this. + ConnectorTableSchema schema = + ((ConnectorPassthroughSqlOps) metadata).getColumnsFromQuery(session, query); return ConnectorColumnConverter.convertColumns(schema.getColumns()); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/QueryTableValueFunction.java b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/QueryTableValueFunction.java index 799b8299167921..853fde6b10f3da 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/QueryTableValueFunction.java +++ b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/QueryTableValueFunction.java @@ -22,9 +22,12 @@ import org.apache.doris.catalog.Column; import org.apache.doris.catalog.Env; import org.apache.doris.common.AnalysisException; -import org.apache.doris.connector.api.ConnectorCapability; +import org.apache.doris.connector.api.ConnectorMetadata; +import org.apache.doris.connector.api.ConnectorPassthroughSqlOps; +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.planner.PlanNodeId; import org.apache.doris.planner.ScanNode; @@ -72,15 +75,19 @@ public static QueryTableValueFunction createQueryTableValueFunction(Map must fail loud at registration // instead of surfacing as a confusing failure the first time someone writes to a branch. // MUTATION: dropping the `!` in the validator's #2 check makes this test go red (see task report). - Connector fake = Mockito.mock(Connector.class); - Mockito.when(fake.supportsWriteBranch()).thenReturn(true); - Mockito.when(fake.supportedWriteOperations()).thenReturn(EnumSet.noneOf(WriteOperation.class)); + ConnectorWritePlanProvider provider = writeProvider(); + Connector fake = connectorWith(provider); + Mockito.when(provider.supportsWriteBranch()).thenReturn(true); + Mockito.when(provider.supportedOperations()).thenReturn(EnumSet.noneOf(WriteOperation.class)); IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, () -> ConnectorContractValidator.validate(fake, "fake_branch_no_insert")); @@ -61,10 +64,11 @@ void validatorRejectsLocalSortWithoutParallelAndFullSchema() { // columns and depends on full-schema positional output, so declaring local-sort without the // other two is self-contradictory and must fail loud rather than silently mis-plan the sink // distribution (PhysicalConnectorTableSink.getRequirePhysicalProperties reads these). - Connector fake = Mockito.mock(Connector.class); - Mockito.when(fake.requiresPartitionLocalSort()).thenReturn(true); - Mockito.when(fake.requiresParallelWrite()).thenReturn(false); - Mockito.when(fake.requiresFullSchemaWriteOrder()).thenReturn(true); + ConnectorWritePlanProvider provider = writeProvider(); + Connector fake = connectorWith(provider); + Mockito.when(provider.requiresPartitionLocalSort()).thenReturn(true); + Mockito.when(provider.requiresParallelWrite()).thenReturn(false); + Mockito.when(provider.requiresFullSchemaWriteOrder()).thenReturn(true); IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, () -> ConnectorContractValidator.validate(fake, "fake_localsort_no_parallel")); @@ -79,10 +83,11 @@ void validatorRejectsLocalSortWithoutFullSchema() { // that validatorRejectsLocalSortWithoutParallelAndFullSchema cannot exercise (it fixes parallel=F). A // mutant dropping the `&& requiresFullSchemaWriteOrder()` conjunct still throws on that other case but // NOT here, so this test is what actually kills that mutation — both conjuncts of #3 are now covered. - Connector fake = Mockito.mock(Connector.class); - Mockito.when(fake.requiresPartitionLocalSort()).thenReturn(true); - Mockito.when(fake.requiresParallelWrite()).thenReturn(true); - Mockito.when(fake.requiresFullSchemaWriteOrder()).thenReturn(false); + ConnectorWritePlanProvider provider = writeProvider(); + Connector fake = connectorWith(provider); + Mockito.when(provider.requiresPartitionLocalSort()).thenReturn(true); + Mockito.when(provider.requiresParallelWrite()).thenReturn(true); + Mockito.when(provider.requiresFullSchemaWriteOrder()).thenReturn(false); IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, () -> ConnectorContractValidator.validate(fake, "fake_localsort_no_fullschema")); @@ -96,10 +101,11 @@ void validatorRejectsHashWriteWithoutParallelAndFullSchema() { // implies BOTH requiresParallelWrite() AND requiresFullSchemaWriteOrder() — the hash arm in // PhysicalConnectorTableSink indexes partition columns by full-schema position and distributes in // parallel, so declaring hash-write without the other two must fail loud, not silently mis-plan. - Connector fake = Mockito.mock(Connector.class); - Mockito.when(fake.requiresPartitionHashWrite()).thenReturn(true); - Mockito.when(fake.requiresParallelWrite()).thenReturn(false); - Mockito.when(fake.requiresFullSchemaWriteOrder()).thenReturn(true); + ConnectorWritePlanProvider provider = writeProvider(); + Connector fake = connectorWith(provider); + Mockito.when(provider.requiresPartitionHashWrite()).thenReturn(true); + Mockito.when(provider.requiresParallelWrite()).thenReturn(false); + Mockito.when(provider.requiresFullSchemaWriteOrder()).thenReturn(true); IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, () -> ConnectorContractValidator.validate(fake, "fake_hash_no_parallel")); @@ -114,11 +120,12 @@ void validatorRejectsBothPartitionDistributionArms() { // both would silently get the local-sort arm and never the hash-without-sort it asked for. That is a // misconfiguration, so it must fail loud at registration. Both are otherwise internally consistent // (parallel + full-schema) to isolate the mutual-exclusion check as the sole reason for the throw. - Connector fake = Mockito.mock(Connector.class); - Mockito.when(fake.requiresParallelWrite()).thenReturn(true); - Mockito.when(fake.requiresFullSchemaWriteOrder()).thenReturn(true); - Mockito.when(fake.requiresPartitionLocalSort()).thenReturn(true); - Mockito.when(fake.requiresPartitionHashWrite()).thenReturn(true); + ConnectorWritePlanProvider provider = writeProvider(); + Connector fake = connectorWith(provider); + Mockito.when(provider.requiresParallelWrite()).thenReturn(true); + Mockito.when(provider.requiresFullSchemaWriteOrder()).thenReturn(true); + Mockito.when(provider.requiresPartitionLocalSort()).thenReturn(true); + Mockito.when(provider.requiresPartitionHashWrite()).thenReturn(true); IllegalStateException ex = Assertions.assertThrows(IllegalStateException.class, () -> ConnectorContractValidator.validate(fake, "fake_both_arms")); @@ -130,13 +137,14 @@ void validatorRejectsBothPartitionDistributionArms() { void validatorPassesForAHashWriteConnector() { // Positive control (Rule 9) for the hive-shaped connector: parallel write + full-schema write order + // hash-write (no local sort), INSERT/OVERWRITE, no branch — internally consistent, must NOT throw. - Connector fake = Mockito.mock(Connector.class); - Mockito.when(fake.supportedWriteOperations()) + ConnectorWritePlanProvider provider = writeProvider(); + Connector fake = connectorWith(provider); + Mockito.when(provider.supportedOperations()) .thenReturn(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE)); - Mockito.when(fake.supportsWriteBranch()).thenReturn(false); - Mockito.when(fake.requiresParallelWrite()).thenReturn(true); - Mockito.when(fake.requiresFullSchemaWriteOrder()).thenReturn(true); - Mockito.when(fake.requiresPartitionHashWrite()).thenReturn(true); + Mockito.when(provider.supportsWriteBranch()).thenReturn(false); + Mockito.when(provider.requiresParallelWrite()).thenReturn(true); + Mockito.when(provider.requiresFullSchemaWriteOrder()).thenReturn(true); + Mockito.when(provider.requiresPartitionHashWrite()).thenReturn(true); Assertions.assertDoesNotThrow(() -> ConnectorContractValidator.validate(fake, "fake_hash_consistent")); } @@ -147,14 +155,39 @@ void validatorPassesForAnInternallyConsistentConnector() { // partition-local sort, INSERT/OVERWRITE, no branch) satisfies both invariants and must NOT throw. // Without this, a validator bug that always throws would make the two negative tests above pass // for the wrong reason. - Connector fake = Mockito.mock(Connector.class); - Mockito.when(fake.supportedWriteOperations()) + ConnectorWritePlanProvider provider = writeProvider(); + Connector fake = connectorWith(provider); + Mockito.when(provider.supportedOperations()) .thenReturn(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE)); - Mockito.when(fake.supportsWriteBranch()).thenReturn(false); - Mockito.when(fake.requiresParallelWrite()).thenReturn(true); - Mockito.when(fake.requiresFullSchemaWriteOrder()).thenReturn(true); - Mockito.when(fake.requiresPartitionLocalSort()).thenReturn(true); + Mockito.when(provider.supportsWriteBranch()).thenReturn(false); + Mockito.when(provider.requiresParallelWrite()).thenReturn(true); + Mockito.when(provider.requiresFullSchemaWriteOrder()).thenReturn(true); + Mockito.when(provider.requiresPartitionLocalSort()).thenReturn(true); Assertions.assertDoesNotThrow(() -> ConnectorContractValidator.validate(fake, "fake_consistent")); } + + /** + * A write plan provider with every trait at its default (false / no operations). supportedOperations is + * stubbed explicitly because a bare Mockito mock would answer null and the validator reads the set. + */ + private static ConnectorWritePlanProvider writeProvider() { + ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); + Mockito.when(provider.supportedOperations()).thenReturn(EnumSet.noneOf(WriteOperation.class)); + return provider; + } + + private static Connector connectorWith(ConnectorWritePlanProvider provider) { + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); + return connector; + } + + @Test + void validatorPassesForAConnectorWithoutWriteSupport() { + // A connector exposing NO write plan provider declares no write capability at all, so no invariant + // can be violated. MUTATION: dropping the null guard makes this throw NullPointerException. + Connector fake = connectorWith(null); + Assertions.assertDoesNotThrow(() -> ConnectorContractValidator.validate(fake, "fake_read_only")); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java index 821b65e0685416..07539049b86822 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java @@ -22,20 +22,26 @@ import org.apache.doris.connector.api.ConnectorSession; import org.apache.doris.connector.spi.ConnectorContext; import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.datasource.CatalogFactory; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.Map; +import java.util.Set; /** - * Tests for {@link ConnectorPluginManager}, focusing on API version - * compatibility checking (P1-9). + * Tests for {@link ConnectorPluginManager}: API version compatibility, the type-name contract enforced when a + * provider is discovered, and the split between sibling lookup and building a standalone catalog. */ public class ConnectorPluginManagerTest { + private static final int CURRENT = ConnectorPluginManager.CURRENT_API_VERSION; + private ConnectorPluginManager manager; private ConnectorContext testContext; @@ -113,7 +119,188 @@ void testNoMatchingProviderReturnsNull() { "No matching provider should return null"); } + @Test + void siblingOnlyTypeIsReachableForSiblingLookupButNeverAsACatalog() { + // A sibling-only connector serves a table format parasitic on another connector's metastore (hudi on an + // HMS catalog): the gateway builds it through createConnector, and that is its ONLY way in. If the + // standalone filter leaked into createConnector, every such table would stop being readable; if the + // filter were missing from createStandaloneCatalogConnector, CREATE CATALOG could build a catalog with + // no engine-side semantics behind it. Both directions must hold at once. + manager.registerProvider(createProvider("sibling_only", CURRENT, false, "sib")); + + Assertions.assertNotNull(manager.createConnector("sibling_only", Collections.emptyMap(), testContext), + "sibling lookup must still reach a sibling-only connector — it has no other entry point"); + Assertions.assertNull( + manager.createStandaloneCatalogConnector("sibling_only", Collections.emptyMap(), testContext), + "a sibling-only connector must never back a standalone catalog"); + } + + @Test + void anyRegisteredTypeCanBackACatalog_noEngineSideList() { + // The point of removing the catalog type allow-list: a type name the engine has never heard of becomes + // usable purely by registering a provider for it. This cannot pass while an engine-side list of + // accepted types exists. + manager.registerProvider(createProvider("acme-lake", CURRENT, true, "acme")); + + Assertions.assertNotNull( + manager.createStandaloneCatalogConnector("acme-lake", Collections.emptyMap(), testContext), + "a third-party type must be routed to its provider without any engine-side registration"); + Assertions.assertEquals(Collections.singletonList("acme-lake"), manager.getStandaloneCatalogTypes(), + "a standalone type must be listed as creatable"); + } + + @Test + void siblingOnlyTypeIsNotListedAsCreatable() { + // The list feeds the CREATE CATALOG diagnostic; naming a type that can never be created would send the + // user chasing a value the engine will always reject. + manager.registerProvider(createProvider("sibling_only", CURRENT, false, "sib")); + + Assertions.assertTrue(manager.getStandaloneCatalogTypes().isEmpty(), + "sibling-only types must not appear among the creatable catalog types"); + Assertions.assertEquals(Collections.singletonList("sibling_only"), manager.getRegisteredTypes(), + "it is still a registered provider — only its eligibility for a catalog differs"); + } + + @Test + void duplicateTypeNameOnClasspathFailsLoud() { + // Type names are the identity CREATE CATALOG routes on and the anchor of source-prefixed namespaces. + // Two providers claiming one name on the classpath is a build error, and the winner would be decided by + // ServiceLoader order — silently, differently per build. + Assertions.assertTrue(manager.registerDiscovered(createProvider("dup", CURRENT, true, "first"), true)); + + IllegalStateException e = Assertions.assertThrows(IllegalStateException.class, + () -> manager.registerDiscovered(createProvider("DUP", CURRENT, true, "second"), true), + "a classpath duplicate must fail loud, and case must not be a way around it"); + Assertions.assertTrue(e.getMessage().contains("already claimed"), e.getMessage()); + } + + @Test + void duplicateTypeNameInPluginDirectoryIsSkippedButDoesNotStopFe() { + // Same conflict, different blame: two plugin directories shipping one type name is a deployment + // accident. loadPlugins promises partial success — one bad plugin dir must not keep FE from starting — + // so the offender is skipped and the incumbent keeps serving. + Assertions.assertTrue(manager.registerDiscovered(createProvider("dup", CURRENT, true, "first"), false)); + Assertions.assertFalse(manager.registerDiscovered(createProvider("dup", CURRENT, true, "second"), false), + "the second claimant must be refused, not silently appended"); + + Assertions.assertEquals(Collections.singletonList("dup"), manager.getRegisteredTypes(), + "the type must be claimed exactly once"); + Connector connector = manager.createConnector("dup", Collections.emptyMap(), testContext); + Assertions.assertEquals("first", ((TaggedConnector) connector).tag, + "the provider that claimed the name first must keep serving it"); + } + + @Test + void providerClaimingAnEngineBuiltinCatalogTypeIsRefused() { + // Reserving the engine's own catalog type names is what makes the plugin-first routing order safe: a + // plugin declaring itself "doris" could otherwise quietly take over every remote-Doris catalog a user + // creates. Refusing it at registration means the shadowing case cannot arise at all. + for (String builtin : new String[] {"doris", "test", "lakesoul"}) { + Assertions.assertTrue(CatalogFactory.isBuiltinCatalogType(builtin), builtin); + Assertions.assertFalse(manager.registerDiscovered(createProvider(builtin, CURRENT, true, "x"), false), + "a plugin must not be allowed to claim engine built-in type '" + builtin + "'"); + Assertions.assertThrows(IllegalStateException.class, + () -> manager.registerDiscovered(createProvider(builtin.toUpperCase(), CURRENT, true, "x"), + true), + "on the classpath the same violation must fail loud, case-insensitively"); + } + Assertions.assertTrue(manager.getRegisteredTypes().isEmpty(), + "no refused provider may end up in the registry"); + } + + @Test + void blankTypeNameIsRefused() { + // getType() is now the sole admission ticket for third-party code; a blank name would otherwise sit in + // the registry and match nothing, or match everything the moment someone compares loosely. + Assertions.assertFalse(manager.registerDiscovered(createProvider(" ", CURRENT, true, "x"), false), + "a blank type name must be refused"); + Assertions.assertTrue(manager.getRegisteredTypes().isEmpty()); + } + + @Test + void registerProviderStillShadowsADiscoveredType() { + // registerProvider exists to stand in for a real plugin in tests (several rely on shadowing a real + // type name). The uniqueness check must not reach it, or those tests lose their only seam. + Assertions.assertTrue(manager.registerDiscovered(createProvider("iceberg", CURRENT, true, "real"), false)); + manager.registerProvider(createProvider("iceberg", CURRENT, true, "override")); + + Connector connector = manager.createConnector("iceberg", Collections.emptyMap(), testContext); + Assertions.assertEquals("override", ((TaggedConnector) connector).tag, + "the explicitly registered provider must win over the discovered one"); + } + + @Test + void duplicateCreateTableEngineNameIsRefused() { + // Engine names route CREATE TABLE ... ENGINE= the same way type names route CREATE CATALOG. Two + // plugins answering to one engine name would make the statement mean whichever registered first, so + // the conflict is refused where it can still be refused: at registration. + Assertions.assertTrue(manager.registerDiscovered( + createProviderWithEngines("first_type", "shared_engine"), false)); + Assertions.assertFalse(manager.registerDiscovered( + createProviderWithEngines("second_type", "SHARED_ENGINE"), false), + "a second claimant of the same engine name must be refused, case-insensitively"); + + Assertions.assertEquals(Collections.singletonList("first_type"), manager.getRegisteredTypes(), + "the refused provider must not end up in the registry"); + + IllegalStateException e = Assertions.assertThrows(IllegalStateException.class, + () -> manager.registerDiscovered(createProviderWithEngines("third_type", "shared_engine"), true), + "on the classpath the same conflict must fail loud"); + Assertions.assertTrue(e.getMessage().contains("already claimed"), e.getMessage()); + } + + @Test + void providerClaimingAnEngineReservedEngineNameIsRefused() { + // olap is the internal catalog's own engine, and odbc/mysql/broker are retired table types that still + // owe the user a specific "use X instead" message from InternalCatalog. A plugin claiming one would + // silently take over a statement the engine answers for. + for (String reserved : new String[] {"olap", "mysql", "odbc", "broker"}) { + Assertions.assertFalse(manager.registerDiscovered( + createProviderWithEngines("t_" + reserved, reserved.toUpperCase()), false), + "a plugin must not be allowed to claim reserved engine name '" + reserved + "'"); + } + Assertions.assertTrue(manager.getRegisteredTypes().isEmpty(), + "no refused provider may end up in the registry"); + } + + @Test + void refusedProviderDoesNotLeaveItsTypeOrEngineNameClaimed() { + // The checks run before anything is claimed, so a provider rejected for one reason must not poison the + // name it never got. Otherwise a bad plugin directory could permanently disable a good one. + Assertions.assertFalse(manager.registerDiscovered(createProviderWithEngines("good_type", "olap"), false), + "rejected for the reserved engine name"); + + Assertions.assertTrue(manager.registerDiscovered(createProviderWithEngines("good_type", "good_engine"), + false), + "the type name must still be free after the earlier provider was refused"); + Assertions.assertEquals(Collections.singletonList("good_type"), manager.getRegisteredTypes()); + } + + private static ConnectorProvider createProviderWithEngines(String type, String... engineNames) { + Set engines = new HashSet<>(Arrays.asList(engineNames)); + return new ConnectorProvider() { + @Override + public String getType() { + return type; + } + + @Override + public Set acceptedCreateTableEngineNames() { + return engines; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + return new TaggedConnector(type); + } + }; + } + private static ConnectorProvider createProvider(String type, int apiVersion) { + return createProvider(type, apiVersion, true, ""); + } + + private static ConnectorProvider createProvider(String type, int apiVersion, boolean standalone, String tag) { return new ConnectorProvider() { @Override public String getType() { @@ -125,19 +312,33 @@ public int apiVersion() { return apiVersion; } + @Override + public boolean isStandaloneCatalogType() { + return standalone; + } + @Override public Connector create(Map properties, ConnectorContext context) { - return new Connector() { - @Override - public ConnectorMetadata getMetadata(ConnectorSession session) { - return null; - } - - @Override - public void close() { - } - }; + return new TaggedConnector(tag); } }; } + + /** A connector that remembers which provider made it, so selection can be asserted. */ + private static final class TaggedConnector implements Connector { + private final String tag; + + private TaggedConnector(String tag) { + this.tag = tag; + } + + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public void close() { + } + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorWriteDelegationTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorWriteDelegationTest.java index 6474ca0ae9ea90..c95f845a3d6b15 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorWriteDelegationTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorWriteDelegationTest.java @@ -19,6 +19,7 @@ import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorSession; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.ConnectorWriteHandle; import org.apache.doris.connector.api.handle.WriteOperation; import org.apache.doris.connector.api.write.ConnectorSinkPlan; @@ -32,26 +33,24 @@ import java.util.Set; /** - * Pins {@link Connector}'s null-safe write-capability delegators - * ({@code supportedWriteOperations} + the 5 {@code requiresXxx}/{@code supportsWriteBranch} views): a - * connector with no write provider must report no write capability (empty operation set, every trait - * {@code false}) rather than NPE, and a connector whose provider overrides - * {@link ConnectorWritePlanProvider#supportedOperations()} / {@link ConnectorWritePlanProvider#requiresParallelWrite()} - * must have that reflected unchanged through {@link Connector}. + * Pins the ONE forwarding the entry interface still performs on the write side: a connector that does not + * override the per-table {@link Connector#getWritePlanProvider(ConnectorTableHandle)} must fall back to its + * connector-level {@link Connector#getWritePlanProvider()}. + * + *

      Why this matters: the engine always asks for the per-table provider, because a heterogeneous + * gateway needs the handle to pick the right one. Every single-format connector overrides only the no-arg + * getter, so if that default stopped forwarding, each of them would silently answer "no write support" and + * every INSERT into a jdbc / maxcompute / iceberg catalog would be rejected at planning. The gateway side of + * the same seam is pinned by {@code HiveConnectorWriteProviderDivertTest}.

      + * + *

      The write traits themselves are NOT reachable from {@link Connector} and deliberately have no test here: + * they are declared on {@link ConnectorWritePlanProvider} and read from there, so there is no second answer + * that could drift from the first.

      */ public class ConnectorWriteDelegationTest { @Test - void delegatorsReflectProviderAndAreNullSafe() { - Connector noWrite = Mockito.mock(Connector.class, Mockito.CALLS_REAL_METHODS); - Mockito.when(noWrite.getWritePlanProvider()).thenReturn(null); - Assertions.assertTrue(noWrite.supportedWriteOperations().isEmpty()); - Assertions.assertFalse(noWrite.supportsWriteBranch()); - Assertions.assertFalse(noWrite.requiresParallelWrite()); - Assertions.assertFalse(noWrite.requiresFullSchemaWriteOrder()); - Assertions.assertFalse(noWrite.requiresPartitionLocalSort()); - Assertions.assertFalse(noWrite.requiresMaterializeStaticPartitionValues()); - + void perTableProviderFallsBackToTheConnectorLevelOne() { ConnectorWritePlanProvider prov = new ConnectorWritePlanProvider() { @Override public ConnectorSinkPlan planWrite(ConnectorSession s, ConnectorWriteHandle h) { @@ -62,17 +61,27 @@ public ConnectorSinkPlan planWrite(ConnectorSession s, ConnectorWriteHandle h) { public Set supportedOperations() { return EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE); } - - @Override - public boolean requiresParallelWrite() { - return true; - } }; - Connector w = Mockito.mock(Connector.class, Mockito.CALLS_REAL_METHODS); - Mockito.when(w.getWritePlanProvider()).thenReturn(prov); + // CALLS_REAL_METHODS so the interface default runs; only the no-arg getter is overridden, exactly as + // every single-format connector does. + Connector single = Mockito.mock(Connector.class, Mockito.CALLS_REAL_METHODS); + Mockito.when(single.getWritePlanProvider()).thenReturn(prov); + + // MUTATION: making the per-table default return null instead of forwarding -> red, and every + // single-format connector loses writes. + Assertions.assertSame(prov, single.getWritePlanProvider(Mockito.mock(ConnectorTableHandle.class)), + "a connector that does not select a provider per table must reuse its connector-level one"); Assertions.assertEquals(EnumSet.of(WriteOperation.INSERT, WriteOperation.OVERWRITE), - w.supportedWriteOperations()); - Assertions.assertTrue(w.requiresParallelWrite()); - Assertions.assertFalse(w.requiresPartitionLocalSort()); + single.getWritePlanProvider(Mockito.mock(ConnectorTableHandle.class)).supportedOperations()); + } + + @Test + void connectorWithoutWriteSupportAnswersNullOnBothGetters() { + // The null provider IS the "no write support" declaration -- every engine-side write gate is written + // as a null check against it, so both getters must agree. + Connector noWrite = Mockito.mock(Connector.class, Mockito.CALLS_REAL_METHODS); + + Assertions.assertNull(noWrite.getWritePlanProvider()); + Assertions.assertNull(noWrite.getWritePlanProvider(Mockito.mock(ConnectorTableHandle.class))); } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextCleanupTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextCleanupTest.java index aa97df02c0a556..6509b00f6315f9 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextCleanupTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/DefaultConnectorContextCleanupTest.java @@ -26,7 +26,7 @@ import java.util.Arrays; /** - * Tests the engine-side empty-directory pruning that backs {@link ConnectorContext#cleanupEmptyManagedLocation} + * Tests the engine-side empty-directory pruning that backs {@link ConnectorStorageContext#cleanupEmptyManagedLocation} * (ported into {@link DefaultConnectorContext} from legacy {@code IcebergMetadataOps}). Uses the reusable * {@link MemoryFileSystem} fake — no live storage. Verifies the conservative contract: a directory is removed * only when it (transitively) contains no files; the table path descends the engine-format child dirs first. diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ExternalMetaCacheInvalidatorTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ExternalMetaCacheInvalidatorTest.java deleted file mode 100644 index 94f50a91138613..00000000000000 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/ExternalMetaCacheInvalidatorTest.java +++ /dev/null @@ -1,107 +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; - -import org.apache.doris.catalog.Env; -import org.apache.doris.datasource.ExternalMetaCacheMgr; - -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; - -import java.util.Arrays; - -/** - * Verifies {@link ExternalMetaCacheInvalidator} routes each SPI invalidate* - * call to the right method on {@link ExternalMetaCacheMgr}, scoped to the - * catalog id captured at construction time. - * - *

      The static {@code Env.getCurrentEnv()} is stubbed via Mockito so the - * test runs without bringing up the full FE. - */ -public class ExternalMetaCacheInvalidatorTest { - - private static final long CATALOG_ID = 42L; - - @Test - public void invalidateAllRoutesToInvalidateCatalog() { - runWithMockedMgr(mgr -> { - new ExternalMetaCacheInvalidator(CATALOG_ID).invalidateAll(); - Mockito.verify(mgr).invalidateCatalog(CATALOG_ID); - Mockito.verifyNoMoreInteractions(mgr); - }); - } - - @Test - public void invalidateDatabaseRoutesToInvalidateDb() { - runWithMockedMgr(mgr -> { - new ExternalMetaCacheInvalidator(CATALOG_ID).invalidateDatabase("sales"); - Mockito.verify(mgr).invalidateDb(CATALOG_ID, "sales"); - Mockito.verifyNoMoreInteractions(mgr); - }); - } - - @Test - public void invalidateTableRoutesToInvalidateTable() { - runWithMockedMgr(mgr -> { - new ExternalMetaCacheInvalidator(CATALOG_ID).invalidateTable("sales", "orders"); - Mockito.verify(mgr).invalidateTable(CATALOG_ID, "sales", "orders"); - Mockito.verifyNoMoreInteractions(mgr); - }); - } - - /** - * Partition-scope invalidation currently falls back to table-level invalidation - * because the SPI carries partition column values, not names — see the inline - * comment in {@link ExternalMetaCacheInvalidator#invalidatePartition}. This - * test pins the documented behavior so a future SPI extension that allows the - * scope to narrow is forced to update the bridge AND this test together. - */ - @Test - public void invalidatePartitionFallsBackToInvalidateTable() { - runWithMockedMgr(mgr -> { - new ExternalMetaCacheInvalidator(CATALOG_ID) - .invalidatePartition("sales", "orders", Arrays.asList("2024", "01")); - Mockito.verify(mgr).invalidateTable(CATALOG_ID, "sales", "orders"); - Mockito.verifyNoMoreInteractions(mgr); - }); - } - - /** - * Stats-only invalidation is intentionally a no-op today — see the inline - * comment in {@link ExternalMetaCacheInvalidator#invalidateStatistics}. - * Verifying zero interactions makes any silent change visible. - */ - @Test - public void invalidateStatisticsIsNoopForNow() { - runWithMockedMgr(mgr -> { - new ExternalMetaCacheInvalidator(CATALOG_ID).invalidateStatistics("sales", "orders"); - Mockito.verifyNoInteractions(mgr); - }); - } - - private static void runWithMockedMgr(java.util.function.Consumer body) { - ExternalMetaCacheMgr mgr = Mockito.mock(ExternalMetaCacheMgr.class); - Env env = Mockito.mock(Env.class); - Mockito.when(env.getExtMetaCacheMgr()).thenReturn(mgr); - try (MockedStatic envStatic = Mockito.mockStatic(Env.class)) { - envStatic.when(Env::getCurrentEnv).thenReturn(env); - body.accept(mgr); - } - } -} diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverterTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverterTest.java index 207afcabfb4f13..c51e9ec390bf3b 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverterTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverterTest.java @@ -32,6 +32,7 @@ import org.apache.doris.nereids.trees.plans.commands.info.ColumnDefinition; import org.apache.doris.nereids.trees.plans.commands.info.CreateTableInfo; import org.apache.doris.nereids.trees.plans.commands.info.DistributionDescriptor; +import org.apache.doris.nereids.trees.plans.commands.info.PartitionDefinition; import org.apache.doris.nereids.trees.plans.commands.info.PartitionTableInfo; import org.apache.doris.nereids.trees.plans.commands.info.SortFieldInfo; import org.apache.doris.nereids.types.IntegerType; @@ -72,7 +73,6 @@ public void columnsAndScalarFieldsArePassedThrough() { null, "an orders table", ImmutableMap.of("k", "v"), - true, true); ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter @@ -83,7 +83,6 @@ public void columnsAndScalarFieldsArePassedThrough() { Assertions.assertEquals("an orders table", req.getComment()); Assertions.assertEquals(ImmutableMap.of("k", "v"), req.getProperties()); Assertions.assertTrue(req.isIfNotExists()); - Assertions.assertTrue(req.isExternal()); Assertions.assertEquals(2, req.getColumns().size()); ConnectorColumn col0 = req.getColumns().get(0); @@ -112,7 +111,7 @@ public void autoIncInitValueIsPropagatedAsIsAutoInc() { Mockito.when(autoIncCol.getAutoIncInitValue()).thenReturn(1L); // != -1 => auto-increment CreateTableInfo info = stubInfo("t", Collections.singletonList(autoIncCol), - null, null, "", Collections.emptyMap(), false, false); + null, null, "", Collections.emptyMap(), false); ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); // WHY (Rule 9): the connector can only reject what the converter carries. This proves the @@ -127,7 +126,7 @@ public void autoIncInitValueIsPropagatedAsIsAutoInc() { public void sortOrderIsCarriedThrough() { ColumnDefinition idCol = new ColumnDefinition("id", IntegerType.INSTANCE, false, ""); CreateTableInfo info = stubInfo("t", Collections.singletonList(idCol), - null, null, "", Collections.emptyMap(), false, false); + null, null, "", Collections.emptyMap(), false); Mockito.when(info.getSortOrderFields()).thenReturn(Arrays.asList( new SortFieldInfo("id", true, false), new SortFieldInfo("name", false, true))); @@ -152,7 +151,7 @@ public void sortOrderIsCarriedThrough() { public void sortOrderEmptyWhenAbsent() { ColumnDefinition idCol = new ColumnDefinition("id", IntegerType.INSTANCE, false, ""); CreateTableInfo info = stubInfo("t", Collections.singletonList(idCol), - null, null, "", Collections.emptyMap(), false, false); + null, null, "", Collections.emptyMap(), false); // getSortOrderFields() unstubbed -> null -> the converter yields an empty (never null) list. ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); Assertions.assertTrue(req.getSortOrder().isEmpty()); @@ -169,7 +168,7 @@ public void plainColumnIsNotAutoInc() { Mockito.when(plainCol.getAutoIncInitValue()).thenReturn(-1L); // default => not auto-increment CreateTableInfo info = stubInfo("t", Collections.singletonList(plainCol), - null, null, "", Collections.emptyMap(), false, false); + null, null, "", Collections.emptyMap(), false); ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); // WHY: guards the `!= -1` predicate boundary -- a normal column must map to false, not true @@ -192,7 +191,7 @@ public void aggTypePropagatedAsIsAggregated() { Mockito.when(aggCol.getAggType()).thenReturn(AggregateType.SUM); CreateTableInfo info = stubInfo("t", Collections.singletonList(aggCol), - null, null, "", Collections.emptyMap(), false, false); + null, null, "", Collections.emptyMap(), false); ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); // WHY (Rule 9): the connector can only reject what the converter carries. This proves the @@ -215,7 +214,7 @@ public void plainColumnIsNotAggregated() { Mockito.when(plainCol.getAggType()).thenReturn(null); // no aggregate type CreateTableInfo info = stubInfo("t", Collections.singletonList(plainCol), - null, null, "", Collections.emptyMap(), false, false); + null, null, "", Collections.emptyMap(), false); ConnectorCreateTableRequest req = CreateTableInfoToConnectorRequestConverter.convert(info, "db"); // WHY: guards the boundary -- a normal column (null/NONE aggType) must map to false. @@ -240,7 +239,7 @@ public void identityPartitionStyle() { Assertions.assertEquals("dt", field.getColumnName()); Assertions.assertEquals("identity", field.getTransform()); Assertions.assertTrue(field.getTransformArgs().isEmpty()); - Assertions.assertTrue(spec.getInitialValues().isEmpty()); + Assertions.assertFalse(spec.hasExplicitPartitionValues()); } @Test @@ -287,8 +286,27 @@ public void listPartitionStyle() { Assertions.assertEquals(ConnectorPartitionSpec.Style.LIST, spec.getStyle()); Assertions.assertEquals(1, spec.getFields().size()); Assertions.assertEquals("region", spec.getFields().get(0).getColumnName()); - // initialValues lowering is deferred — see converter inline comment. - Assertions.assertTrue(spec.getInitialValues().isEmpty()); + // WHY: PARTITION BY LIST(region) with no explicit PARTITION (...) clauses must NOT raise the + // presence flag, or hive would reject a partitioned CREATE TABLE it has always accepted. + Assertions.assertFalse(spec.hasExplicitPartitionValues()); + } + + @Test + public void explicitPartitionValueDefinitionsRaiseThePresenceFlag() { + // PARTITION BY LIST (region) (PARTITION p1 VALUES IN (...)) — the value expressions themselves + // never cross the SPI boundary; only the fact that the user wrote some does. + PartitionTableInfo partition = new PartitionTableInfo( + false, + PartitionType.LIST.name(), + Collections.singletonList(Mockito.mock(PartitionDefinition.class)), + ImmutableList.of(new UnboundSlot("region"))); + + ConnectorPartitionSpec spec = convertWithPartition(partition).getPartitionSpec(); + Assertions.assertNotNull(spec); + // WHY: this flag is the ONLY thing a connector can use to reject explicit partition values + // (hive external tables discover partitions from the data layout, legacy parity). + // MUTATION: hard-coding the converter's boolean to false turns this red. + Assertions.assertTrue(spec.hasExplicitPartitionValues()); } @Test @@ -305,7 +323,7 @@ public void rangePartitionStyle() { Assertions.assertEquals(ConnectorPartitionSpec.Style.RANGE, spec.getStyle()); Assertions.assertEquals(1, spec.getFields().size()); Assertions.assertEquals("dt", spec.getFields().get(0).getColumnName()); - Assertions.assertTrue(spec.getInitialValues().isEmpty()); + Assertions.assertFalse(spec.hasExplicitPartitionValues()); } @Test @@ -344,7 +362,6 @@ private static ConnectorCreateTableRequest convertWithPartition( null, "", Collections.emptyMap(), - false, false), "db"); } @@ -359,7 +376,6 @@ private static ConnectorCreateTableRequest convertWithDistribution( distribution, "", Collections.emptyMap(), - false, false), "db"); } @@ -375,8 +391,7 @@ private static CreateTableInfo stubInfo(String tableName, DistributionDescriptor distribution, String comment, java.util.Map properties, - boolean ifNotExists, - boolean external) { + boolean ifNotExists) { CreateTableInfo info = Mockito.mock(CreateTableInfo.class); Mockito.when(info.getTableName()).thenReturn(tableName); Mockito.when(info.getColumnDefinitions()).thenReturn(columns); @@ -385,7 +400,6 @@ private static CreateTableInfo stubInfo(String tableName, Mockito.when(info.getComment()).thenReturn(comment); Mockito.when(info.getProperties()).thenReturn(properties); Mockito.when(info.isIfNotExists()).thenReturn(ifNotExists); - Mockito.when(info.isExternal()).thenReturn(external); return info; } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java b/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java index 0146a432d3857a..bf2187ee201cdd 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java @@ -25,7 +25,6 @@ import org.apache.doris.connector.api.handle.ConnectorTableHandle; import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; import org.apache.doris.connector.spi.ConnectorContext; -import org.apache.doris.connector.spi.ConnectorMetaInvalidator; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -58,23 +57,6 @@ void setUp() { metadata = connector.getMetadata(session); } - // ──────────────────── ConnectorContext defaults ──────────────────── - - @Test - void contextMetaInvalidatorDefaultsToNoop() { - ConnectorContext context = new FakeConnectorPlugin.FakeContext("fake_cat", 1L); - // T04: default getMetaInvalidator() returns NOOP — exercising it must not throw. - Assertions.assertSame(ConnectorMetaInvalidator.NOOP, - context.getMetaInvalidator(), - "default ConnectorContext.getMetaInvalidator() should return NOOP"); - context.getMetaInvalidator().invalidateAll(); - context.getMetaInvalidator().invalidateDatabase("db"); - context.getMetaInvalidator().invalidateTable("db", "t"); - context.getMetaInvalidator().invalidatePartition( - "db", "t", Collections.singletonList("2024")); - context.getMetaInvalidator().invalidateStatistics("db", "t"); - } - // ──────────────────── ConnectorSession defaults ──────────────────── @Test @@ -120,40 +102,53 @@ void tableOpsListDefaults() { Assertions.assertEquals(Optional.empty(), metadata.getTableHandle(session, "db", "t")); - Assertions.assertTrue(metadata.getPrimaryKeys(session, "db", "t").isEmpty()); Assertions.assertEquals("", metadata.getTableComment(session, "db", "t")); } @Test void partitionListingDefaultsToEmpty() { ConnectorTableHandle handle = new ConnectorTableHandle() { }; - // T17-T19: all three listing defaults return empty. + // T17-T18: both listing defaults return empty. Assertions.assertTrue( metadata.listPartitionNames(session, handle).isEmpty()); Assertions.assertTrue( metadata.listPartitions(session, handle, Optional.empty()).isEmpty()); - Assertions.assertTrue( - metadata.listPartitionValues(session, handle, - Collections.singletonList("dt")).isEmpty()); } @Test - void createTableRequestDefaultDegradesToLegacy() { + void createTableDefaultRejectsInsteadOfDegrading() { ConnectorCreateTableRequest request = ConnectorCreateTableRequest.builder() .dbName("db") .tableName("t") .columns(Collections.emptyList()) .properties(Collections.emptyMap()) .build(); - // T14: default createTable(request) falls through to legacy createTable(schema, - // props), whose own default throws "CREATE TABLE not supported". This proves - // the fall-through chain is wired correctly, even if the connector ultimately - // rejects the request. + // WHY: a connector that does not implement CREATE TABLE must FAIL, and it must fail on the request + // overload itself. This default used to build a ConnectorTableSchema and delegate to a narrower + // (schema, properties) overload, which meant the partition spec, the bucket spec and IF NOT EXISTS were + // dropped on the way -- a connector implementing only the narrow form reported success on a partitioned + // CREATE TABLE and produced an unpartitioned table. That degradation path is gone; there is one entry + // point, and not implementing it is an error rather than a silently narrower table. + // MUTATION: reinstating the degrading default (build a schema, do nothing) -> no throw -> red. DorisConnectorException ex = Assertions.assertThrows( DorisConnectorException.class, () -> metadata.createTable(session, request)); Assertions.assertTrue(ex.getMessage().contains("CREATE TABLE not supported"), - "should propagate legacy createTable's error, got: " + ex.getMessage()); + "should reject with the connector-facing message, got: " + ex.getMessage()); + } + + @Test + void dropDatabaseDefaultRejectsInsteadOfSilentlyDroppingForce() { + // WHY: the throw now lives on the 4-arg overload. It used to live on a 3-arg form and this overload + // defaulted to it, discarding `force` -- so DROP DATABASE ... FORCE silently became a non-cascading + // drop that then failed on a non-empty database, with an error about the database not being empty + // rather than about FORCE being unsupported. Nothing implemented the 3-arg form. + // MUTATION: removing the throw from the 4-arg default -> no throw -> red. + DorisConnectorException ex = Assertions.assertThrows( + DorisConnectorException.class, + () -> metadata.dropDatabase(session, "db", false, true)); + Assertions.assertTrue(ex.getMessage().contains("DROP DATABASE not supported"), + "should reject with the connector-facing message, got: " + ex.getMessage()); } // ──────────────────── ConnectorWriteOps defaults ──────────────────── @@ -174,8 +169,6 @@ void beginTransactionDefaultThrows() { void connectorTopLevelDefaults() { Assertions.assertNull(connector.getScanPlanProvider()); Assertions.assertTrue(connector.getCapabilities().isEmpty()); - Assertions.assertTrue(connector.getTableProperties().isEmpty()); - Assertions.assertTrue(connector.getSessionProperties().isEmpty()); Assertions.assertFalse(connector.defaultTestConnection()); Assertions.assertTrue(connector.testConnection(session).isSuccess()); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogFactoryPluginRoutingTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogFactoryPluginRoutingTest.java new file mode 100644 index 00000000000000..77c63cd3c71d16 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/CatalogFactoryPluginRoutingTest.java @@ -0,0 +1,212 @@ +// 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; + +import org.apache.doris.common.DdlException; +import org.apache.doris.common.FeConstants; +import org.apache.doris.connector.ConnectorFactory; +import org.apache.doris.connector.ConnectorPluginManager; +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.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.datasource.log.CatalogLog; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; +import org.apache.doris.nereids.trees.plans.commands.CreateCatalogCommand; + +import com.google.common.collect.Maps; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * How {@link CatalogFactory} decides which catalog type is served by what. + * + *

      The engine holds no list of accepted catalog types: a type is creatable because some registered connector + * provider claims it, or because the engine implements that type itself. These tests pin the consequences of + * that: a type the engine has never heard of is creatable, a sibling-only connector still is not, a typo fails + * loud, and edit-log replay of an unserved type does not take FE down with it. + * + *

      Routing is asserted by counting whether the provider was actually consulted; the catalog class alone + * cannot tell "built from its plugin" apart from "registered degraded", since both are plugin-driven catalogs. + */ +public class CatalogFactoryPluginRoutingTest { + + private static final String THIRD_PARTY_TYPE = "acme-lake"; + + @BeforeEach + void setUp() { + // The plugin manager is a static singleton shared with every other test in the fork: start from a known + // empty one rather than inheriting whatever the previous test class registered. + ConnectorFactory.initPluginManager(new ConnectorPluginManager()); + } + + @AfterEach + void tearDown() { + ConnectorFactory.initPluginManager(new ConnectorPluginManager()); + } + + @Test + void typeUnknownToTheEngineIsCreatableViaItsPlugin() throws Exception { + // The whole point of dropping the catalog type allow-list. Before it was dropped this type could not be + // created no matter how correctly its plugin was written, installed and loaded: CREATE CATALOG never + // reached the provider, because the type name was not one of seven strings compiled into fe-core. + AtomicInteger consulted = registerProvider(THIRD_PARTY_TYPE, true); + + CatalogIf catalog = CatalogFactory.createFromLog(catalogLog("acme_ctl", props(THIRD_PARTY_TYPE))); + + Assertions.assertEquals(1, consulted.get(), + "the provider claiming this type must be asked to build the connector"); + Assertions.assertInstanceOf(PluginDrivenExternalCatalog.class, catalog); + } + + @Test + void siblingOnlyTypeStillCannotBeCreatedAsACatalog() { + // A sibling-only connector (hudi is the real one) serves tables parasitic on another connector's + // metastore and has no catalog class behind it in the engine. Dropping the allow-list must not turn its + // internal lookup key into a user-facing catalog type — that would build a catalog with no semantics. + AtomicInteger consulted = registerProvider("sibling_only", false); + + DdlException e = Assertions.assertThrows(DdlException.class, + () -> CatalogFactory.createFromCommand(1L, command("sib_ctl", props("sibling_only")))); + + Assertions.assertEquals(0, consulted.get(), "a sibling-only provider must not be asked to back a catalog"); + Assertions.assertTrue(e.getMessage().contains("No connector plugin claimed"), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("Installed connector types: []"), + "a sibling-only type must not be advertised as a creatable catalog type: " + e.getMessage()); + } + + @Test + void anUnclaimedTypeFailsLoudAndNamesTheInstalledTypes() { + // Without this, dropping the allow-list would degrade into "any typo silently builds a broken catalog". + // The installed-type list is what tells a user whether they mistyped or forgot to install the plugin. + registerProvider("iceberg", true); + + DdlException e = Assertions.assertThrows(DdlException.class, + () -> CatalogFactory.createFromCommand(1L, command("typo_ctl", props("icebrg")))); + + Assertions.assertTrue(e.getMessage().contains("icebrg"), e.getMessage()); + Assertions.assertTrue(e.getMessage().contains("Installed connector types: [iceberg]"), + "the installed types must be named so a typo is self-diagnosing: " + e.getMessage()); + } + + @Test + void replayOfAnUnservedTypeDegradesInsteadOfKillingFe() throws Exception { + // Replaying an edit log must never throw here: EditLog's fallback catch turns any exception from a + // replayed operation into System.exit(-1) unless its op code is listed in + // skip_operation_types_on_replay_exception (empty by default). So a plugin removed from the plugin + // directory would keep the whole FE from starting instead of making one catalog unusable. Registering it + // degraded defers the failure to first access, where it is a normal query error. + AtomicInteger consulted = registerProvider("iceberg", true); + + CatalogIf catalog = CatalogFactory.createFromLog(catalogLog("gone_ctl", props(THIRD_PARTY_TYPE))); + + Assertions.assertEquals(0, consulted.get(), "no registered provider claims this type"); + Assertions.assertInstanceOf(PluginDrivenExternalCatalog.class, catalog, + "the catalog must still be registered, so that only accessing it fails"); + } + + @Test + void everyReservedTypeNameIsStillServedByTheEngineItself() throws Exception { + // CatalogFactory.BUILTIN_CATALOG_TYPES is what makes those names unclaimable by a plugin. If a name were + // listed there but had no case in the switch, it would silently stop being a catalog type at all and + // start reporting "no connector plugin claimed" — this loop is the guard against that drift. + boolean savedRunningUnitTest = FeConstants.runningUnitTest; + FeConstants.runningUnitTest = true; + try { + for (String builtin : CatalogFactory.BUILTIN_CATALOG_TYPES) { + Map props = props(builtin); + // TestExternalCatalog reads its provider from this property; harmless for the other types. + props.put("catalog_provider.class", + "org.apache.doris.datasource.RefreshCatalogTest$RefreshCatalogProvider"); + try { + CatalogIf catalog = CatalogFactory.createFromLog(catalogLog("builtin_" + builtin, props)); + Assertions.assertFalse(catalog instanceof PluginDrivenExternalCatalog, + "reserved type '" + builtin + "' must be served by the engine, not routed to plugins"); + } catch (DdlException e) { + // A reserved type may legitimately refuse (lakesoul is retired), but it must refuse as + // itself, not as an unknown type. + Assertions.assertFalse(e.getMessage().contains("No connector plugin claimed"), + "reserved type '" + builtin + "' has no case in the built-in switch: " + + e.getMessage()); + } + } + } finally { + FeConstants.runningUnitTest = savedRunningUnitTest; + } + } + + /** Registers a single fake provider and returns a counter of how often it was asked to build a connector. */ + private static AtomicInteger registerProvider(String type, boolean standalone) { + AtomicInteger consulted = new AtomicInteger(); + ConnectorPluginManager manager = new ConnectorPluginManager(); + manager.registerProvider(new ConnectorProvider() { + @Override + public String getType() { + return type; + } + + @Override + public boolean isStandaloneCatalogType() { + return standalone; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + consulted.incrementAndGet(); + return new Connector() { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public void close() { + } + }; + } + }); + ConnectorFactory.initPluginManager(manager); + return consulted; + } + + private static Map props(String type) { + Map props = Maps.newHashMap(); + props.put(CatalogMgr.CATALOG_TYPE_PROP, type); + return props; + } + + private static CatalogLog catalogLog(String name, Map props) { + CatalogLog log = new CatalogLog(); + log.setCatalogId(1L); + log.setCatalogName(name); + log.setResource(""); + log.setComment(""); + log.setProps(props); + return log; + } + + private static CreateCatalogCommand command(String name, Map props) { + return new CreateCatalogCommand(name, false, "", "", props); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/MetastoreEventSyncDriverSeedingTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/MetastoreEventSyncDriverSeedingTest.java new file mode 100644 index 00000000000000..abac904622b3ac --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/MetastoreEventSyncDriverSeedingTest.java @@ -0,0 +1,173 @@ +// 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; + +import org.apache.doris.connector.ConnectorFactory; +import org.apache.doris.connector.ConnectorPluginManager; +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.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorProvider; +import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; + +import com.google.common.collect.Maps; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Which uninitialized catalogs the metastore-event driver is allowed to force-initialize. + * + *

      The driver initializes a catalog nobody has queried on this FE so it can obtain its event source and + * seed its cursor — required on followers, which normally receive no queries at all. Two opposite mistakes + * are possible and neither shows up as an error:

      + *
        + *
      • too narrow — a connector that has an event source is left out, and its catalog silently never syncs + * incrementally on an FE that has not queried it (this is what a hardcoded {@code "hms"} type check + * did to every other connector);
      • + *
      • too wide — idle catalogs of every other type get force-initialized on a timer, i.e. connect to + * remote metastores nobody asked about.
      • + *
      + * + *

      So both directions are asserted, by counting whether the catalog was actually touched.

      + */ +public class MetastoreEventSyncDriverSeedingTest { + + private static final String WITH_EVENTS = "fake-with-events"; + private static final String WITHOUT_EVENTS = "fake-without-events"; + + @BeforeEach + void setUp() { + // The plugin manager is a static singleton shared with every other test in the fork. + ConnectorFactory.initPluginManager(new ConnectorPluginManager()); + } + + @AfterEach + void tearDown() { + ConnectorFactory.initPluginManager(new ConnectorPluginManager()); + } + + @Test + public void catalogOfAnEventSourceTypeIsForceInitialized() { + registerProviders(); + CountingCatalog catalog = new CountingCatalog(WITH_EVENTS); + + boolean polled = new MetastoreEventSyncDriver().seedCursorOfUninitializedCatalog(catalog); + + // The declaring type must be initialized even though nothing on this FE has queried it — otherwise + // its cursor is never seeded and the catalog only starts syncing after someone happens to query it. + Assertions.assertEquals(1, catalog.initCount.get(), + "a catalog whose type declares an event source must be force-initialized"); + // Our fake init throws, mirroring an unreachable metastore: the driver swallows it and retries the + // next cycle rather than aborting the whole sweep. + Assertions.assertFalse(polled); + } + + @Test + public void catalogWithoutAnEventSourceIsNeverTouched() { + registerProviders(); + CountingCatalog catalog = new CountingCatalog(WITHOUT_EVENTS); + + boolean polled = new MetastoreEventSyncDriver().seedCursorOfUninitializedCatalog(catalog); + + // The guard that keeps idle catalogs inert: no connection, no metadata load, no init. MUTATION: + // dropping the providesEventSource() check -> every idle plugin catalog is initialized on a timer. + Assertions.assertEquals(0, catalog.initCount.get(), + "an idle catalog of a type without an event source must not be touched at all"); + Assertions.assertFalse(polled); + } + + @Test + public void catalogOfAnUnregisteredTypeIsNeverTouched() { + // No provider registered at all (plugin not installed / not loaded yet). + CountingCatalog catalog = new CountingCatalog(WITH_EVENTS); + + Assertions.assertFalse(new MetastoreEventSyncDriver().seedCursorOfUninitializedCatalog(catalog)); + Assertions.assertEquals(0, catalog.initCount.get()); + } + + private static void registerProviders() { + ConnectorPluginManager manager = new ConnectorPluginManager(); + manager.registerProvider(new FakeProvider(WITH_EVENTS, true)); + manager.registerProvider(new FakeProvider(WITHOUT_EVENTS, false)); + ConnectorFactory.initPluginManager(manager); + } + + /** A plugin catalog that records force-initialization instead of performing one. */ + private static final class CountingCatalog extends PluginDrivenExternalCatalog { + private final AtomicInteger initCount = new AtomicInteger(); + + private CountingCatalog(String type) { + super(1L, "ctl_" + type, null, typeProps(type), "", null); + } + + @Override + protected void initLocalObjectsImpl() { + initCount.incrementAndGet(); + // Stop here: a real init would build the meta cache and connect to the remote metastore. The + // driver treats a throwing init as "retry next cycle", which is the behaviour under test for the + // declaring type. + throw new IllegalStateException("test stub: not really initializing"); + } + } + + private static Map typeProps(String type) { + Map props = Maps.newHashMap(); + props.put(CatalogMgr.CATALOG_TYPE_PROP, type); + return props; + } + + private static final class FakeProvider implements ConnectorProvider { + private final String type; + private final boolean withEvents; + + private FakeProvider(String type, boolean withEvents) { + this.type = type; + this.withEvents = withEvents; + } + + @Override + public String getType() { + return type; + } + + @Override + public boolean providesEventSource() { + return withEvents; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + return new Connector() { + @Override + public ConnectorMetadata getMetadata(ConnectorSession session) { + return null; + } + + @Override + public void close() { + } + }; + } + } +} 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 94a0fe54313f69..92a1b1c55266a8 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 @@ -309,15 +309,14 @@ public void sysExternalTableReportsIcebergEngineAndEngineTableTypeName() { PluginDrivenSysExternalTable sys = new PluginDrivenSysExternalTable(base, "snapshots"); // WHY: SHOW TABLE STATUS / information_schema.tables.ENGINE for an iceberg sys table must read - // "iceberg" (not the generic "Plugin"), and getEngineTableTypeName must read "ICEBERG_EXTERNAL_TABLE". - // The sys table inherits both from PluginDrivenExternalTable, which switches on the catalog type; - // T06-F1 pinned the BASE table, this pins the inherited SYS path. MUTATION: deleting the "iceberg" - // case in PluginDrivenExternalTable.getEngine / getEngineTableTypeName -> "Plugin" / - // "PLUGIN_EXTERNAL_TABLE" -> red. assertAll so each pin (two independent T06 behaviors) is caught - // by its own mutation rather than masked by the other's short-circuit. + // "iceberg", and SHOW CREATE TABLE must print ENGINE=iceberg. A sys table inherits both from + // PluginDrivenExternalTable, which asks the catalog for the name its connector declares; the base + // table is pinned by PluginDrivenExternalTableEngineTest, this pins the inherited SYS path. + // MUTATION: making the sys table report the generic plugin name -> red. assertAll so each pin is + // caught by its own mutation rather than masked by the other's short-circuit. Assertions.assertAll( () -> Assertions.assertEquals("iceberg", sys.getEngine()), - () -> Assertions.assertEquals("ICEBERG_EXTERNAL_TABLE", sys.getEngineTableTypeName())); + () -> Assertions.assertEquals("iceberg", sys.getEngineTableTypeName())); } // ==================== generic not-found path (no legacy position_deletes marker) ================ 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 22c5911cc9e490..07f6718987c5b4 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 @@ -41,11 +41,11 @@ import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; import org.apache.doris.connector.api.mvcc.ConnectorTableFreshness; import org.apache.doris.connector.api.mvcc.ConnectorTimeTravelSpec; +import org.apache.doris.connector.api.scan.ConnectorPartitionValues; import org.apache.doris.datasource.ExternalCatalog; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.SchemaCacheValue; import org.apache.doris.datasource.SessionContext; -import org.apache.doris.datasource.TablePartitionValues; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalTable; import org.apache.doris.datasource.plugin.PluginDrivenSchemaCacheValue; @@ -284,17 +284,17 @@ public void testDefaultSentinelWithoutFlagBuildsNonNullStringKey() { // ...BuildsGenuineNullPartition tests below. VARCHAR keeps the sentinel parseable; a non-string column // without the flag throws+drops (per-partition catch) — see testDefaultSentinelWithoutFlagStillDrops. Fixture f = Fixture.with(Collections.singletonList( - cpi("dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION, TS_2024_01_01)), Type.VARCHAR); + cpi("dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME, TS_2024_01_01)), Type.VARCHAR); Map items = f.table.getNameToPartitionItems(Optional.empty()); Assertions.assertEquals(1, items.size()); - PartitionItem item = items.get("dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION); + PartitionItem item = items.get("dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME); Assertions.assertTrue(item instanceof ListPartitionItem, "expected a ListPartitionItem"); PartitionKey key = ((ListPartitionItem) item).getItems().get(0); // MUTATION: defaulting the absent flag to isNull=true -> the key is a NullLiteral -> red. Assertions.assertFalse(key.getKeys().get(0).isNullLiteral(), "no-flag default: a __HIVE_DEFAULT_PARTITION__ value must build a NON-null literal key (isNull=false)"); - Assertions.assertEquals(TablePartitionValues.HIVE_DEFAULT_PARTITION, + Assertions.assertEquals(ConnectorPartitionValues.NULL_PARTITION_NAME, key.getKeys().get(0).getStringValue(), "the no-flag partition key must carry the sentinel string verbatim (a plain StringLiteral)"); } @@ -307,7 +307,7 @@ public void testDefaultSentinelWithNullFlagOnIntColumnBuildsGenuineNullPartition // isNull=true flag the value builds a typed NullLiteral (no parse), so the table stays LIST-partitioned // with a genuine-NULL partition (legacy HiveExternalMetaCache:309 parity; hive/paimon variant B). Fixture f = Fixture.with(Collections.singletonList( - cpiNull("dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION, TS_2024_01_01, true)), Type.INT); + cpiNull("dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME, TS_2024_01_01, true)), Type.INT); Assertions.assertEquals(PartitionType.LIST, f.table.getPartitionType(Optional.empty()), "a genuine-NULL INT partition must NOT collapse the table to UNPARTITIONED"); @@ -316,7 +316,7 @@ public void testDefaultSentinelWithNullFlagOnIntColumnBuildsGenuineNullPartition Map items = f.table.getNameToPartitionItems(Optional.empty()); Assertions.assertEquals(1, items.size(), "the null partition must be present, not dropped"); PartitionKey key = ((ListPartitionItem) items.get( - "dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION)).getItems().get(0); + "dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME)).getItems().get(0); // MUTATION: ignoring the flag (hardcoded false) -> IntLiteral parse throws -> 0 items / UNPARTITIONED -> red. Assertions.assertTrue(key.getKeys().get(0).isNullLiteral(), "the connector-supplied NULL flag must build a typed NullLiteral for the INT column"); @@ -326,13 +326,13 @@ public void testDefaultSentinelWithNullFlagOnIntColumnBuildsGenuineNullPartition public void testDefaultSentinelWithNullFlagOnDateColumnBuildsGenuineNullPartition() { // Second non-string family (DATEV2 also throws on the sentinel pre-fix). Same expectation as the INT case. Fixture f = Fixture.with(Collections.singletonList( - cpiNull("dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION, TS_2024_01_01, true)), Type.DATEV2); + cpiNull("dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME, TS_2024_01_01, true)), Type.DATEV2); Assertions.assertEquals(PartitionType.LIST, f.table.getPartitionType(Optional.empty())); Map items = f.table.getNameToPartitionItems(Optional.empty()); Assertions.assertEquals(1, items.size()); PartitionKey key = ((ListPartitionItem) items.get( - "dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION)).getItems().get(0); + "dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME)).getItems().get(0); Assertions.assertTrue(key.getKeys().get(0).isNullLiteral(), "the connector-supplied NULL flag must build a typed NullLiteral for the DATE column"); } @@ -343,7 +343,7 @@ public void testDefaultSentinelWithoutFlagStillDrops() { // non-string column — the sentinel throws on IntLiteral, the partition is dropped, the table degrades to // UNPARTITIONED. (Compile-independent guard: uses only the pre-existing no-flag cpi helper.) Fixture f = Fixture.with(Collections.singletonList( - cpi("dt=" + TablePartitionValues.HIVE_DEFAULT_PARTITION, TS_2024_01_01)), Type.INT); + cpi("dt=" + ConnectorPartitionValues.NULL_PARTITION_NAME, TS_2024_01_01)), Type.INT); Assertions.assertEquals(PartitionType.UNPARTITIONED, f.table.getPartitionType(Optional.empty()), "without the connector flag, an INT sentinel still drops the partition (UNPARTITIONED)"); 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 3f7a262680fcf2..ca010872f145c1 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 @@ -175,9 +175,8 @@ public void testCreateDbWrapsConnectorException() { } @Test - public void testCreateDbIfNotExistsSkipsWhenRemoteExistsAndConnectorSupportsCreate() throws Exception { + public void testCreateDbIfNotExistsSkipsWhenRemoteExists() throws Exception { catalog.dbNullableResult = null; // FE-cache miss - Mockito.when(metadata.supportsCreateDatabase()).thenReturn(true); Mockito.when(metadata.databaseExists(session, "db1")).thenReturn(true); catalog.createDb("db1", true, new HashMap<>()); @@ -195,7 +194,6 @@ public void testCreateDbIfNotExistsSkipsWhenRemoteExistsAndConnectorSupportsCrea @Test public void testCreateDbIfNotExistsCreatesWhenRemoteAbsent() throws Exception { catalog.dbNullableResult = null; // FE-cache miss - Mockito.when(metadata.supportsCreateDatabase()).thenReturn(true); Mockito.when(metadata.databaseExists(session, "db1")).thenReturn(false); // absent remotely Map props = new HashMap<>(); @@ -211,23 +209,41 @@ public void testCreateDbIfNotExistsCreatesWhenRemoteAbsent() throws Exception { } @Test - public void testCreateDbIfNotExistsBypassesPrecheckWhenConnectorLacksCreateSupport() throws Exception { + public void testCreateDbIfNotExistsSucceedsWhenConnectorCannotCreateButDbExists() throws Exception { catalog.dbNullableResult = null; // FE-cache miss - // supportsCreateDatabase() defaults to false on the mock -- the connector cannot create - // databases (jdbc/es/trino). databaseExists is intentionally NOT stubbed: it must never - // be consulted (the && short-circuits on the capability gate). - Map props = new HashMap<>(); + // A connector that cannot create databases (jdbc/es/trino): createDatabase throws the SPI + // default, but the db is already there remotely. + Mockito.when(metadata.databaseExists(session, "db1")).thenReturn(true); + Mockito.doThrow(new DorisConnectorException("CREATE DATABASE not supported")) + .when(metadata).createDatabase(Mockito.any(), Mockito.any(), Mockito.any()); - catalog.createDb("db1", true, props); + catalog.createDb("db1", true, new HashMap<>()); - // WHY (Rule 9): the capability gate keeps jdbc/es/trino byte-identical -- a connector that - // cannot create databases must fall through to createDatabase ("not supported" in - // production), and the && must short-circuit so the remote databaseExists query is never - // even issued. MUTATION: dropping the `supportsCreateDatabase() &&` gate makes databaseExists - // get consulted here -> the never().databaseExists verify goes red (createDatabase still runs - // because databaseExists defaults to false; the gate's job is to skip the remote probe). - Mockito.verify(metadata, Mockito.never()).databaseExists(Mockito.any(), Mockito.any()); - Mockito.verify(metadata).createDatabase(session, "db1", props); + // WHY (Rule 9): the existence precheck is asked of EVERY connector, not only those that can + // create -- IF NOT EXISTS means "ensure it is there", and it is, so the statement must + // succeed instead of reporting "CREATE DATABASE not supported" (this is Trino's + // CreateSchemaTask behavior; it replaced a supportsCreateDatabase() gate that could only + // restate whether createDatabase was overridden). MUTATION: re-gating the precheck on any + // capability sends this through createDatabase -> the stubbed throw fails the test. + Mockito.verify(metadata).databaseExists(session, "db1"); + Mockito.verify(metadata, Mockito.never()).createDatabase(Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.verify(mockEditLog, Mockito.never()).logCreateDb(Mockito.any()); + } + + @Test + public void testCreateDbIfNotExistsStillReachesConnectorWhenDbAbsent() throws Exception { + catalog.dbNullableResult = null; // FE-cache miss + // databaseExists defaults to false on the mock: a connector that answers neither question is + // byte-identical to before -- it falls through to createDatabase ("not supported"). + Mockito.doThrow(new DorisConnectorException("CREATE DATABASE not supported")) + .when(metadata).createDatabase(Mockito.any(), Mockito.any(), Mockito.any()); + + DdlException ex = Assertions.assertThrows(DdlException.class, + () -> catalog.createDb("db1", true, new HashMap<>())); + + // WHY: the precheck must not degrade IF NOT EXISTS into "never fail" -- an absent db on a + // connector that cannot create one still surfaces the connector's refusal. + Assertions.assertTrue(ex.getMessage().contains("CREATE DATABASE not supported")); } // ==================== DROP DATABASE ==================== diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableEngineTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableEngineTest.java index dbc761a119e23f..18350100effa77 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableEngineTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTableEngineTest.java @@ -20,6 +20,8 @@ import org.apache.doris.catalog.Column; import org.apache.doris.catalog.PrimitiveType; import org.apache.doris.catalog.TableIf.TableType; +import org.apache.doris.connector.ConnectorFactory; +import org.apache.doris.connector.ConnectorPluginManager; import org.apache.doris.connector.api.Connector; import org.apache.doris.connector.api.ConnectorColumn; import org.apache.doris.connector.api.ConnectorMetadata; @@ -27,13 +29,17 @@ import org.apache.doris.connector.api.ConnectorTableSchema; import org.apache.doris.connector.api.ConnectorType; import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.spi.ConnectorContext; +import org.apache.doris.connector.spi.ConnectorProvider; import org.apache.doris.datasource.ExternalDatabase; import org.apache.doris.datasource.SchemaCacheValue; import org.apache.doris.datasource.SessionContext; import org.apache.doris.thrift.TTableDescriptor; import org.apache.doris.thrift.TTableType; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; @@ -46,13 +52,28 @@ import java.util.Optional; /** - * Tests that {@link PluginDrivenExternalTable} returns the correct legacy engine - * names and table type names for migrated JDBC/ES catalogs, preserving - * user-visible compatibility across metadata surfaces (SHOW TABLE STATUS, - * information_schema.tables, REST API, etc.). + * What a plugin-driven table answers when asked for its engine name — the {@code ENGINE} column of + * {@code SHOW TABLE STATUS} and {@code information_schema.tables}, and the string {@code SHOW CREATE TABLE} + * prints after {@code ENGINE=}. + * + *

      The engine holds no mapping from data source to displayed name: the name comes from the connector's + * provider, which defaults it to the catalog type. These tests pin that — a catalog type the engine has never + * heard of gets its own name, and a connector that spells its name differently from its type is obeyed. */ public class PluginDrivenExternalTableEngineTest { + @BeforeEach + void setUp() { + // The plugin manager is a static singleton shared with every other test in the fork: start from a known + // empty one rather than inheriting whatever the previous test class registered. + ConnectorFactory.initPluginManager(new ConnectorPluginManager()); + } + + @AfterEach + void tearDown() { + ConnectorFactory.initPluginManager(new ConnectorPluginManager()); + } + @Test public void testJdbcCatalogReturnsJdbcEngineName() { PluginDrivenExternalTable table = createTableWithCatalogType("jdbc"); @@ -68,92 +89,110 @@ public void testEsCatalogReturnsEsEngineName() { } @Test - public void testMaxComputeCatalogReturnsLegacyEngineName() { + public void testMaxComputeCatalogReturnsTheNameItsConnectorDeclares() { + // The one connector whose displayed name is not its catalog type: the type a user writes is + // "max_compute", the product is spelled "maxcompute". Only the provider knows that, which is the whole + // point — the engine cannot special-case it. MUTATION: dropping the provider's displayEngineName() + // override -> "max_compute" -> red. + registerProvider("max_compute", "maxcompute"); PluginDrivenExternalTable table = createTableWithCatalogType("max_compute"); - // Legacy MaxComputeExternalTable did not override getEngine(); its type - // MAX_COMPUTE_EXTERNAL_TABLE has no case in TableType.toEngineName(), so the - // engine name was null. The migrated table must reproduce that exactly, - // otherwise SHOW TABLE STATUS / information_schema.tables would regress. - Assertions.assertNull(table.getEngine(), - "MaxCompute catalog tables should report the legacy null engine name"); + Assertions.assertEquals("maxcompute", table.getEngine(), + "MaxCompute catalog tables should report the name its connector declares"); } @Test - public void testUnknownCatalogReturnsPluginEngineName() { + public void testUnknownCatalogReturnsItsCatalogType() { + // No provider registered for this type at all, which is also what a catalog whose plugin was uninstalled + // looks like: the name falls back to the catalog type rather than becoming a generic placeholder or + // throwing. It must NOT be a fixed string — that would mean the engine still owns the mapping. PluginDrivenExternalTable table = createTableWithCatalogType("custom_type"); - Assertions.assertEquals("Plugin", table.getEngine(), - "Unknown catalog types should report engine='Plugin'"); + Assertions.assertEquals("custom_type", table.getEngine(), + "A catalog type with no registered provider should report its own type as the engine"); } @Test public void testJdbcCatalogReturnsJdbcEngineTableTypeName() { PluginDrivenExternalTable table = createTableWithCatalogType("jdbc"); - Assertions.assertEquals(TableType.JDBC_EXTERNAL_TABLE.name(), - table.getEngineTableTypeName(), - "JDBC catalog tables should report JDBC_EXTERNAL_TABLE type name"); + Assertions.assertEquals("jdbc", table.getEngineTableTypeName(), + "SHOW CREATE TABLE on a JDBC catalog table should print ENGINE=jdbc"); } @Test public void testEsCatalogReturnsEsEngineTableTypeName() { PluginDrivenExternalTable table = createTableWithCatalogType("es"); - Assertions.assertEquals(TableType.ES_EXTERNAL_TABLE.name(), - table.getEngineTableTypeName(), - "ES catalog tables should report ES_EXTERNAL_TABLE type name"); + Assertions.assertEquals("es", table.getEngineTableTypeName(), + "SHOW CREATE TABLE on an ES catalog table should print ENGINE=es"); } @Test public void testMaxComputeCatalogReturnsMaxComputeEngineTableTypeName() { + registerProvider("max_compute", "maxcompute"); PluginDrivenExternalTable table = createTableWithCatalogType("max_compute"); - Assertions.assertEquals(TableType.MAX_COMPUTE_EXTERNAL_TABLE.name(), - table.getEngineTableTypeName(), - "MaxCompute catalog tables should report MAX_COMPUTE_EXTERNAL_TABLE type name"); + Assertions.assertEquals("maxcompute", table.getEngineTableTypeName(), + "SHOW CREATE TABLE on a MaxCompute catalog table should print ENGINE=maxcompute"); } @Test - public void testUnknownCatalogReturnsPluginEngineTableTypeName() { + public void testUnknownCatalogReturnsItsCatalogTypeAsEngineTableTypeName() { PluginDrivenExternalTable table = createTableWithCatalogType("custom_type"); - Assertions.assertEquals(TableType.PLUGIN_EXTERNAL_TABLE.name(), - table.getEngineTableTypeName(), - "Unknown catalog types should report PLUGIN_EXTERNAL_TABLE type name"); + Assertions.assertEquals("custom_type", table.getEngineTableTypeName(), + "SHOW CREATE TABLE on a catalog with no provider should print its catalog type"); } @Test public void testIcebergCatalogReturnsIcebergEngineName() { - // P6.5-T06: after the iceberg cutover (P6.6) a base/sys iceberg table is a PluginDrivenExternalTable; - // legacy IcebergExternalTable reported engine "iceberg" (TableType.ICEBERG_EXTERNAL_TABLE.toEngineName()). - // Without an iceberg case it would fall through to "Plugin", regressing SHOW TABLE STATUS / - // information_schema.tables. MUTATION: dropping the iceberg case -> "Plugin" -> red. PluginDrivenExternalTable table = createTableWithCatalogType("iceberg"); Assertions.assertEquals("iceberg", table.getEngine(), - "Iceberg catalog tables should report engine='iceberg' (legacy parity), not 'Plugin'"); + "Iceberg catalog tables should report engine='iceberg'"); } @Test public void testIcebergCatalogReturnsIcebergEngineTableTypeName() { PluginDrivenExternalTable table = createTableWithCatalogType("iceberg"); - Assertions.assertEquals(TableType.ICEBERG_EXTERNAL_TABLE.name(), - table.getEngineTableTypeName(), - "Iceberg catalog tables should report ICEBERG_EXTERNAL_TABLE type name"); + Assertions.assertEquals("iceberg", table.getEngineTableTypeName(), + "SHOW CREATE TABLE on an iceberg catalog table should print ENGINE=iceberg"); } @Test public void testHmsCatalogReturnsHmsEngineName() { - // HMS cutover: after the flip an HMS external catalog is a PluginDrivenExternalCatalog (type - // "hms"); legacy HMSExternalTable displayed engine "hms" (TableType.HMS_EXTERNAL_TABLE.toEngineName()), - // NOT "hive" (that is the CREATE-TABLE engine, a separate concern). Without an "hms" case it would - // fall through to "Plugin", regressing SHOW TABLE STATUS / information_schema.tables. - // MUTATION: dropping the hms case -> "Plugin" -> red; returning "hive" -> red. + // An HMS catalog displays "hms", NOT the "hive" its connector accepts in CREATE TABLE ... ENGINE=. + // Displaying and accepting are answered by two different provider methods precisely because a + // connector may need them to differ; this is the one that does. + registerProvider("hms", "hms"); PluginDrivenExternalTable table = createTableWithCatalogType("hms"); Assertions.assertEquals("hms", table.getEngine(), - "Hms catalog tables should report engine='hms' (legacy parity), not 'Plugin' or 'hive'"); + "Hms catalog tables should report engine='hms', not the accepted CREATE TABLE engine 'hive'"); } @Test public void testHmsCatalogReturnsHmsEngineTableTypeName() { + registerProvider("hms", "hms"); PluginDrivenExternalTable table = createTableWithCatalogType("hms"); - Assertions.assertEquals(TableType.HMS_EXTERNAL_TABLE.name(), - table.getEngineTableTypeName(), - "Hms catalog tables should report HMS_EXTERNAL_TABLE type name"); + Assertions.assertEquals("hms", table.getEngineTableTypeName(), + "SHOW CREATE TABLE on an HMS catalog table should print ENGINE=hms"); + } + + @Test + public void testEngineNameComesFromTheConnectorNotFromAnyEngineSideTable() { + // The regression guard for the whole change: a catalog type fe-core has never heard of, whose connector + // spells its displayed name differently again. Nothing in the engine could produce this answer from a + // hardcoded list. MUTATION: resolving the name from the catalog type instead of the provider -> red. + registerProvider("acme-lake", "AcmeLake"); + PluginDrivenExternalTable table = createTableWithCatalogType("acme-lake"); + Assertions.assertEquals("AcmeLake", table.getEngine(), + "the displayed engine name must be whatever the connector's provider says it is"); + } + + @Test + public void testBothEngineNamesAlwaysAgree() { + // One connector, one engine name, however the user reaches it: SHOW TABLE STATUS and SHOW CREATE TABLE + // must never disagree. MUTATION: giving getEngineTableTypeName() its own derivation -> red. + registerProvider("max_compute", "maxcompute"); + for (String catalogType : new String[] {"jdbc", "iceberg", "max_compute", "custom_type"}) { + PluginDrivenExternalTable table = createTableWithCatalogType(catalogType); + Assertions.assertEquals(table.getEngine(), table.getEngineTableTypeName(), + "the ENGINE column and the ENGINE= clause must show the same name for " + catalogType); + } } @Test @@ -264,6 +303,29 @@ public List getFullSchema() { // -------- Helpers -------- + /** Registers a single fake provider claiming {@code type} and displaying {@code displayName}. */ + private static void registerProvider(String type, String displayName) { + ConnectorPluginManager manager = new ConnectorPluginManager(); + manager.registerProvider(new ConnectorProvider() { + @Override + public String getType() { + return type; + } + + @Override + public String displayEngineName() { + return displayName; + } + + @Override + public Connector create(Map properties, ConnectorContext context) { + throw new UnsupportedOperationException( + "the displayed engine name must be answered without building a connector"); + } + }); + ConnectorFactory.initPluginManager(manager); + } + private PluginDrivenExternalTable createTableWithCatalogType(String catalogType) { return createTableWithCatalogType(catalogType, createMockConnector(true, false)); } 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 d6be2c1bad6acd..82fe28985d8d78 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 @@ -242,7 +242,7 @@ public void testGetTablePropertiesStripsSchemaControlKeysButKeepsUserOptions() { rawProps.put("path", "s3://wh/db/t"); rawProps.put("file.format", "orc"); rawProps.put(ConnectorTableSchema.PARTITION_COLUMNS_KEY, "dt"); - rawProps.put(ConnectorTableSchema.PRIMARY_KEYS_KEY, "id"); + rawProps.put(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY, "id"); PluginDrivenSchemaCacheValue cacheValue = new PluginDrivenSchemaCacheValue( Collections.singletonList(new Column("id", PrimitiveType.INT)), Collections.emptyList(), Collections.emptyList(), rawProps); @@ -258,8 +258,8 @@ public void testGetTablePropertiesStripsSchemaControlKeysButKeepsUserOptions() { Assertions.assertEquals("orc", props.get("file.format")); Assertions.assertFalse(props.containsKey(ConnectorTableSchema.PARTITION_COLUMNS_KEY), "the reserved partition-columns key must not appear in SHOW CREATE PROPERTIES"); - Assertions.assertFalse(props.containsKey(ConnectorTableSchema.PRIMARY_KEYS_KEY), - "the reserved primary-keys key must not appear in SHOW CREATE PROPERTIES"); + Assertions.assertFalse(props.containsKey(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY), + "the reserved distribution-columns key must not appear in SHOW CREATE PROPERTIES"); } @Test 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 92d8bcc2ef6238..f95e3e93168f49 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 @@ -109,12 +109,16 @@ private static PluginDrivenExternalTable capabilityTable(boolean handlePresent, ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) .thenReturn(handlePresent ? Optional.of(handle) : Optional.empty()); + // The write traits live on the provider; the table resolves its handle and asks the PER-HANDLE + // provider, so stub them where they are actually declared. + ConnectorWritePlanProvider provider = Mockito.mock(ConnectorWritePlanProvider.class); + Mockito.when(provider.supportedOperations()).thenReturn(ops); + Mockito.when(provider.supportsWriteBranch()).thenReturn(branch); + Mockito.when(provider.requiresPartitionHashWrite()).thenReturn(true); + Mockito.when(provider.requiresMaterializeStaticPartitionValues()).thenReturn(true); Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); - Mockito.when(connector.supportedWriteOperations(Mockito.any())).thenReturn(ops); - Mockito.when(connector.supportsWriteBranch(Mockito.any())).thenReturn(branch); - Mockito.when(connector.requiresPartitionHashWrite(Mockito.any())).thenReturn(true); - Mockito.when(connector.requiresMaterializeStaticPartitionValues(Mockito.any())).thenReturn(true); + Mockito.when(connector.getWritePlanProvider(Mockito.any())).thenReturn(provider); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); ConnectorSession session = noneScopedSession(); @@ -431,24 +435,38 @@ public void getFullSchemaDegradesWhenTableHandleAbsent() { * exercise the capability-helper methods over the real connector chain. */ private static PluginDrivenExternalTable pluginTableWithCapabilities(Set capabilities) { - return pluginTableWithCapabilities(capabilities, Collections.emptyMap()); + return pluginTableWithCapabilities(capabilities, Collections.emptySet()); } /** * Builds a CALLS_REAL_METHODS PluginDrivenExternalTable whose connector declares {@code capabilities} - * connector-wide AND whose cached schema emits {@code perTableProps} as its raw table-property map (carrying - * the {@code connector.per-table-capabilities} marker for heterogeneous connectors like hive). Exercises the - * additive connector-wide-OR-per-table resolution in {@code hasScanCapability}. makeSureInitialized is - * stubbed to a no-op (no Env-backed init in a unit test). + * connector-wide AND whose cached schema carries {@code perTableCapabilities} (what a heterogeneous + * connector like hive declares for one specific table). Exercises the additive + * connector-wide-OR-per-table resolution in {@code hasCapability}. makeSureInitialized is stubbed to a + * no-op (no Env-backed init in a unit test). */ private static PluginDrivenExternalTable pluginTableWithCapabilities( - Set capabilities, Map perTableProps) { + Set capabilities, Set perTableCapabilities) { + return pluginTable(capabilities, perTableCapabilities, Collections.emptyMap()); + } + + /** + * Same, for the assertions that read a reserved control key out of the cached raw property map (e.g. the + * distribution-columns marker) rather than the per-table capability set. + */ + private static PluginDrivenExternalTable pluginTableWithProperties(Map tableProperties) { + return pluginTable(EnumSet.noneOf(ConnectorCapability.class), Collections.emptySet(), tableProperties); + } + + private static PluginDrivenExternalTable pluginTable(Set capabilities, + Set perTableCapabilities, Map tableProperties) { Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getCapabilities()).thenReturn(capabilities); PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); Mockito.when(catalog.getConnector()).thenReturn(connector); PluginDrivenSchemaCacheValue scv = Mockito.mock(PluginDrivenSchemaCacheValue.class); - Mockito.when(scv.getTableProperties()).thenReturn(perTableProps); + Mockito.when(scv.getTableCapabilities()).thenReturn(perTableCapabilities); + Mockito.when(scv.getTableProperties()).thenReturn(tableProperties); PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class, Mockito.CALLS_REAL_METHODS); Deencapsulation.setField(table, "catalog", catalog); @@ -466,19 +484,36 @@ public void supportsColumnAutoAnalyzeReflectsConnectorCapability() { EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE)).supportsTopNLazyMaterialize()); Assertions.assertFalse(pluginTableWithCapabilities( EnumSet.noneOf(ConnectorCapability.class)).supportsColumnAutoAnalyze()); - // Auto-analyze is now resolved per-table (like Top-N / nested-prune): a heterogeneous hive catalog emits it - // via the connector.per-table-capabilities marker for its plain-hive tables (and reflects the iceberg - // sibling's set onto an iceberg-on-HMS table) even when the CATALOG connector-wide set lacks it. MUTATION: - // reverting supportsColumnAutoAnalyze() to a connector-wide-only read ignores this marker, so a flipped + // Auto-analyze is now resolved per-table (like Top-N / nested-prune): a heterogeneous hive catalog + // declares it per-table for its plain-hive tables (and reflects the iceberg sibling's set onto an + // iceberg-on-HMS table) even when the CATALOG connector-wide set lacks it. MUTATION: reverting + // supportsColumnAutoAnalyze() to a connector-wide-only read ignores the per-table set, so a flipped // plain-hive / iceberg-on-HMS table silently drops out of auto-analyze -> red here. - Map autoAnalyzeMarker = Collections.singletonMap( - ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, - ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE.name()); + Set autoAnalyzeOnly = EnumSet.of(ConnectorCapability.SUPPORTS_COLUMN_AUTO_ANALYZE); Assertions.assertTrue(pluginTableWithCapabilities( - EnumSet.noneOf(ConnectorCapability.class), autoAnalyzeMarker).supportsColumnAutoAnalyze()); - // The marker is capability-specific: an auto-analyze marker must NOT enable Top-N. + EnumSet.noneOf(ConnectorCapability.class), autoAnalyzeOnly).supportsColumnAutoAnalyze()); + // The per-table set is capability-specific: auto-analyze must NOT enable Top-N. Assertions.assertFalse(pluginTableWithCapabilities( - EnumSet.noneOf(ConnectorCapability.class), autoAnalyzeMarker).supportsTopNLazyMaterialize()); + EnumSet.noneOf(ConnectorCapability.class), autoAnalyzeOnly).supportsTopNLazyMaterialize()); + } + + @Test + public void capabilityResolutionUnionsConnectorWideAndPerTableSets() { + // WHY: the two sources are ADDITIVE, and no other case in this class ever sets BOTH non-empty — so a + // mutation replacing the union with "per-table wins" (or "connector-wide wins") would pass every other + // assertion here while silently disabling a capability on exactly the tables a heterogeneous catalog + // cares about. Pin both directions of the union explicitly. + // MUTATION: turning `connectorWide.contains(c) || perTable.contains(c)` into either arm alone -> red. + PluginDrivenExternalTable table = pluginTableWithCapabilities( + EnumSet.of(ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE), + EnumSet.of(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)); + Assertions.assertTrue(table.supportsNestedColumnPrune(), + "a connector-wide capability must survive a non-empty per-table set"); + Assertions.assertTrue(table.supportsSampleAnalyze(), + "a per-table capability must survive a non-empty connector-wide set"); + // ...and neither arm may leak into a capability declared by nobody. + Assertions.assertFalse(table.supportsTopNLazyMaterialize(), + "a capability in neither set must stay off"); } @Test @@ -491,8 +526,7 @@ public void supportsSampleAnalyzeReflectsConnectorCapability() { EnumSet.of(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)).supportsSampleAnalyze()); Assertions.assertTrue(pluginTableWithCapabilities( EnumSet.noneOf(ConnectorCapability.class), - Collections.singletonMap(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, - ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE.name())).supportsSampleAnalyze()); + EnumSet.of(ConnectorCapability.SUPPORTS_SAMPLE_ANALYZE)).supportsSampleAnalyze()); Assertions.assertFalse(pluginTableWithCapabilities( EnumSet.noneOf(ConnectorCapability.class)).supportsSampleAnalyze()); // Independent: sample must NOT enable auto-analyze (iceberg-on-HMS gets auto-analyze but not sample). @@ -506,7 +540,7 @@ public void getDistributionColumnNamesReadsLowercasedMarker() { // legacy HMSExternalTable.getDistributionColumnNames. Consumed by sampled analyze's linear-vs-DUJ1 NDV // estimator choice. MUTATION: not lowercasing / not reading the marker -> the estimator choice regresses // for a flipped bucketed hive table. - PluginDrivenExternalTable table = pluginTableWithCapabilities(EnumSet.noneOf(ConnectorCapability.class), + PluginDrivenExternalTable table = pluginTableWithProperties( Collections.singletonMap(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY, "Id,Region")); Assertions.assertEquals(new HashSet<>(Arrays.asList("id", "region")), table.getDistributionColumnNames()); // No marker -> empty (paimon/iceberg unchanged, TableIf default). @@ -542,33 +576,29 @@ public void supportsNestedColumnPruneReflectsConnectorCapability() { } @Test - public void scanCapabilityHonorsPerTableMarkerWhenConnectorWideAbsent() { + public void scanCapabilityHonorsPerTableSetWhenConnectorWideAbsent() { // WHY: a heterogeneous connector (hive) cannot declare Top-N lazy / nested-column-prune connector-wide // because eligibility is per-table file-format gated (orc/parquet only) — blanket-declaring would - // over-admit a text/json table (a correctness bug for nested prune). It emits the capability name only - // for eligible tables via the connector.per-table-capabilities schema marker; the helper must honor that - // additively even when the connector-wide set is EMPTY. MUTATION: dropping the per-table marker read -> - // a flipped orc/parquet hive table silently loses the optimization -> red here. - Map topnMarker = Collections.singletonMap( - ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, - ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE.name()); + // over-admit a text/json table (a correctness bug for nested prune). It declares the capability only + // for eligible tables, in the table's own capability set; the resolver must honor that additively even + // when the connector-wide set is EMPTY. MUTATION: dropping the per-table read -> a flipped orc/parquet + // hive table silently loses the optimization -> red here. + Set topnOnly = EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE); Assertions.assertTrue(pluginTableWithCapabilities( - EnumSet.noneOf(ConnectorCapability.class), topnMarker).supportsTopNLazyMaterialize()); - // The marker is capability-specific: a Top-N marker must NOT enable nested-column pruning. + EnumSet.noneOf(ConnectorCapability.class), topnOnly).supportsTopNLazyMaterialize()); + // The per-table set is capability-specific: Top-N must NOT enable nested-column pruning. Assertions.assertFalse(pluginTableWithCapabilities( - EnumSet.noneOf(ConnectorCapability.class), topnMarker).supportsNestedColumnPrune()); - // A multi-value marker enables exactly the listed capabilities. - Map bothMarker = Collections.singletonMap( - ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY, - ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE.name() + "," - + ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE.name()); + EnumSet.noneOf(ConnectorCapability.class), topnOnly).supportsNestedColumnPrune()); + // A multi-value set enables exactly the listed capabilities. + Set both = EnumSet.of(ConnectorCapability.SUPPORTS_TOPN_LAZY_MATERIALIZE, + ConnectorCapability.SUPPORTS_NESTED_COLUMN_PRUNE); Assertions.assertTrue(pluginTableWithCapabilities( - EnumSet.noneOf(ConnectorCapability.class), bothMarker).supportsTopNLazyMaterialize()); + EnumSet.noneOf(ConnectorCapability.class), both).supportsTopNLazyMaterialize()); Assertions.assertTrue(pluginTableWithCapabilities( - EnumSet.noneOf(ConnectorCapability.class), bothMarker).supportsNestedColumnPrune()); - // An empty / absent marker leaves both off (the plain-hive text-table case). + EnumSet.noneOf(ConnectorCapability.class), both).supportsNestedColumnPrune()); + // An empty per-table set leaves both off (the plain-hive text-table case). Assertions.assertFalse(pluginTableWithCapabilities( - EnumSet.noneOf(ConnectorCapability.class), Collections.emptyMap()).supportsTopNLazyMaterialize()); + EnumSet.noneOf(ConnectorCapability.class), Collections.emptySet()).supportsTopNLazyMaterialize()); } @Test @@ -931,7 +961,7 @@ public void getTablePropertiesStripsReservedKeysAndPassesThroughColludingUserKey Map raw = new LinkedHashMap<>(); raw.put("write.format.default", "parquet"); raw.put(ConnectorTableSchema.PARTITION_COLUMNS_KEY, "id"); - raw.put(ConnectorTableSchema.PRIMARY_KEYS_KEY, "id"); + raw.put(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY, "id"); raw.put(ConnectorTableSchema.SHOW_LOCATION_KEY, "s3://bucket/db/t"); raw.put(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY, "PARTITION BY LIST (`id`) ()"); raw.put(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY, "ORDER BY (`id` ASC NULLS FIRST)"); @@ -948,7 +978,7 @@ public void getTablePropertiesStripsReservedKeysAndPassesThroughColludingUserKey Assertions.assertEquals("a_user_value", props.get("partition_columns"), "a user's bare partition_columns property flows through (no collision with the reserved key)"); Assertions.assertFalse(props.containsKey(ConnectorTableSchema.PARTITION_COLUMNS_KEY)); - Assertions.assertFalse(props.containsKey(ConnectorTableSchema.PRIMARY_KEYS_KEY)); + Assertions.assertFalse(props.containsKey(ConnectorTableSchema.DISTRIBUTION_COLUMNS_KEY)); Assertions.assertFalse(props.containsKey(ConnectorTableSchema.SHOW_LOCATION_KEY)); Assertions.assertFalse(props.containsKey(ConnectorTableSchema.SHOW_PARTITION_CLAUSE_KEY)); Assertions.assertFalse(props.containsKey(ConnectorTableSchema.SHOW_SORT_CLAUSE_KEY)); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMvccTableFactoryTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMvccTableFactoryTest.java index a4eb8d5f033012..72c6411b8a6de7 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMvccTableFactoryTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/plugin/PluginDrivenMvccTableFactoryTest.java @@ -78,13 +78,14 @@ public void testBuildsBaseTableWhenConnectorLacksMvccCapability() { @Test public void testBuildsBaseTableWhenConnectorIsNull() { PluginDrivenExternalDatabase db = new PluginDrivenExternalDatabase(); - PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); - Mockito.when(catalog.getConnector()).thenReturn(null); + PluginDrivenExternalCatalog catalog = catalogReturning(null); ExternalTable table = db.buildTableInternal("rt", "lt", 1L, catalog, db); // MUTATION: a missing null-guard (NPE on getCapabilities) makes this red. Lazy-init catalogs - // whose connector is not yet built must fall back to the base class, not crash. + // whose connector is not yet built must fall back to the base class, not crash. This now also + // covers PluginDrivenExternalCatalog.hasConnectorCapability's own null degradation, which is the + // single place every catalog-scope capability check gets it from. Assertions.assertSame(PluginDrivenExternalTable.class, table.getClass(), "a not-yet-built connector must degrade to the base table, never NPE"); } @@ -128,12 +129,23 @@ private static void setExternalTableField(ExternalTable table, String field, Obj } } + /** + * CALLS_REAL_METHODS so the catalog's real {@code hasConnectorCapability} runs over a stubbed connector: + * the factory asks the catalog, and the catalog's null-safe capability lookup is part of what is under + * test here. {@code doReturn} (not {@code when}) because the real {@code getConnector()} would force + * catalog initialization during stubbing. + */ private static PluginDrivenExternalCatalog catalogWithCapabilities(ConnectorCapability... caps) { Connector connector = Mockito.mock(Connector.class); Mockito.when(connector.getCapabilities()).thenReturn( caps.length == 0 ? Collections.emptySet() : Sets.newHashSet(caps)); - PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); - Mockito.when(catalog.getConnector()).thenReturn(connector); + return catalogReturning(connector); + } + + private static PluginDrivenExternalCatalog catalogReturning(Connector connector) { + PluginDrivenExternalCatalog catalog = + Mockito.mock(PluginDrivenExternalCatalog.class, Mockito.CALLS_REAL_METHODS); + Mockito.doReturn(connector).when(catalog).getConnector(); return catalog; } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeExplainStatsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeExplainStatsTest.java index 2e295076b5104b..ad60f505e19bfd 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeExplainStatsTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeExplainStatsTest.java @@ -18,7 +18,6 @@ package org.apache.doris.datasource.scan; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -54,11 +53,6 @@ public class PluginDrivenScanNodeExplainStatsTest { /** Minimal fake range: native flag + optional precomputed count, the only two getters under test. */ private static ConnectorScanRange range(boolean nativeRead, long pushDownRowCount) { return new ConnectorScanRange() { - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Map getProperties() { return Collections.emptyMap(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeFileCacheAdmissionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeFileCacheAdmissionTest.java new file mode 100644 index 00000000000000..a56bc5b6b7cef6 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeFileCacheAdmissionTest.java @@ -0,0 +1,96 @@ +// 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.scan; + +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.connector.api.Connector; +import org.apache.doris.connector.api.handle.ConnectorTableHandle; +import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Which scans BE's file-cache admission governance is evaluated for. + * + *

      It used to be a set of three catalog type names compiled into {@link FileQueryScanNode}. The real + * condition is a property of the connector — whether BE reads its data with the native file readers that + * populate the file cache at all — so it is now the connector's declaration, resolved per table through the + * handle. That distinction is invisible in a homogeneous catalog and decisive in a heterogeneous one, where + * one catalog serves tables of several formats through sibling connectors.

      + * + *

      Why this matters: both failure directions are silent. Skipping the check for a connector whose + * data IS natively read means the user's library/table admission rules quietly do not apply to those tables + * (only a debug log). Running it for a JNI-read connector spends an admission lookup per scan for a cache + * that will never hold its data.

      + */ +public class PluginDrivenScanNodeFileCacheAdmissionTest { + + @Test + public void appliesWhenTheServingConnectorDeclaresNativeFileReads() { + Assertions.assertTrue(admissionApplicable(true), + "a connector whose ranges BE reads natively must stay inside admission governance"); + } + + @Test + public void doesNotApplyWhenTheServingConnectorDeclaresNothing() { + // The SPI default. jdbc / trino / maxcompute / es keep exactly the behaviour they had while the + // catalog-type allow-list decided this. + Assertions.assertFalse(admissionApplicable(false), + "a connector that does not declare native file reads must stay out of admission governance"); + } + + @Test + public void doesNotApplyWhenNoProviderResolves() { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + Connector connector = Mockito.mock(Connector.class); + Mockito.when(connector.getScanPlanProvider(handle)).thenReturn(null); + Deencapsulation.setField(node, "connector", connector); + Deencapsulation.setField(node, "currentHandle", handle); + + // System tables and an unavailable plugin resolve no scan provider; asking one would NPE, and + // defaulting to "governed" would run an admission lookup with no connector behind it. + Assertions.assertFalse( + (boolean) Deencapsulation.invoke(node, "isFileCacheAdmissionApplicable")); + } + + @Test + public void theBaseNodeStaysOutOfGovernanceByDefault() { + // TVF and remote-Doris scan nodes were never in the allow-list; the base default keeps them out + // without either of them having to say anything. + FileQueryScanNode base = Mockito.mock(FileQueryScanNode.class, Mockito.CALLS_REAL_METHODS); + Assertions.assertFalse((boolean) Deencapsulation.invoke(base, "isFileCacheAdmissionApplicable")); + } + + /** Drives the node with a single sibling provider declaring {@code supportsFileCache() == declared}. */ + private static boolean admissionApplicable(boolean declared) { + PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, Mockito.CALLS_REAL_METHODS); + ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class); + ConnectorScanPlanProvider provider = Mockito.mock(ConnectorScanPlanProvider.class); + Mockito.when(provider.supportsFileCache()).thenReturn(declared); + Connector connector = Mockito.mock(Connector.class); + // Resolution goes through the handle, so in a heterogeneous catalog each table is answered by the + // sibling that will actually serve it rather than by the catalog's type. + Mockito.when(connector.getScanPlanProvider(handle)).thenReturn(provider); + Deencapsulation.setField(node, "connector", connector); + Deencapsulation.setField(node, "currentHandle", handle); + return Deencapsulation.invoke(node, "isFileCacheAdmissionApplicable"); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePushdownFactsTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePushdownFactsTest.java new file mode 100644 index 00000000000000..21d6cafa73bd6f --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePushdownFactsTest.java @@ -0,0 +1,79 @@ +// 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.scan; + +import org.apache.doris.connector.api.scan.ScanNodePropertyKeys; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +/** + * Pins the two engine-only facts the generic scan node publishes to connectors: the pushed-down limit, and + * whether the connector took ALL the filtering. + * + *

      WHY they exist: a connector that can ask its source to stop early needs both, and it must not have to be + * recognized by the engine to get them. Until this pair existed, the generic node made that decision itself by + * matching one connector's file-format string — the exact pattern this node's own rule text forbids.

      + * + *

      WHY the values are stringly typed: they ride the same {@code Map} property table the + * connector already receives, which is what lets the fact be added without changing an SPI signature.

      + * + *

      The insertion ORDER (pruning conjuncts before the thrift delegation, so "everything was pushed" is read + * off the pruned set) cannot be covered by a static helper test; it is argued in an ATTN comment at the call + * site and left to review.

      + */ +public class PluginDrivenScanNodePushdownFactsTest { + + private static Map facts(long limit, boolean allPushed) { + Map props = new HashMap<>(); + PluginDrivenScanNode.injectPushdownFacts(props, limit, allPushed); + return props; + } + + @Test + public void publishesTheLimitAndTheAllPushedFlag() { + Map props = facts(5L, true); + Assertions.assertEquals("5", props.get(ScanNodePropertyKeys.SYNTHETIC_PUSHDOWN_LIMIT)); + Assertions.assertEquals("true", props.get(ScanNodePropertyKeys.SYNTHETIC_ALL_CONJUNCTS_PUSHED)); + } + + @Test + public void publishesTheAbsentLimitAsANonPositiveValue() { + // No LIMIT in the query is -1 on the node. A connector reads "not positive" as "no limit"; emitting + // nothing at all would be indistinguishable from an old engine that never set the key. + Assertions.assertEquals("-1", facts(-1L, true).get(ScanNodePropertyKeys.SYNTHETIC_PUSHDOWN_LIMIT)); + } + + @Test + public void publishesFalseWhenFilteringRemains() { + // The correctness-carrying half: a connector that stops its source early while the engine still has + // conjuncts to apply loses rows. + Assertions.assertEquals("false", facts(5L, false).get(ScanNodePropertyKeys.SYNTHETIC_ALL_CONJUNCTS_PUSHED)); + } + + @Test + public void keysAreTheSharedContractNotLocalLiterals() { + // Both sides reference these constants from the public module; the literals are pinned here so a + // rename that misses one side is caught in this repo rather than by a connector silently reading null. + Assertions.assertEquals("__pushdown_limit", ScanNodePropertyKeys.SYNTHETIC_PUSHDOWN_LIMIT); + Assertions.assertEquals("__all_conjuncts_pushed", ScanNodePropertyKeys.SYNTHETIC_ALL_CONJUNCTS_PUSHED); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProfileTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProfileTest.java index f1546fd0afa5e6..b4fed76badbd29 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProfileTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeScanProfileTest.java @@ -18,7 +18,6 @@ package org.apache.doris.datasource.scan; import org.apache.doris.common.profile.RuntimeProfile; -import org.apache.doris.common.profile.SummaryProfile; import org.apache.doris.connector.api.scan.ConnectorScanProfile; import org.junit.jupiter.api.Assertions; @@ -88,13 +87,4 @@ public void sharesGroupAcrossScans() { Assertions.assertEquals("3", group.getChildMap().get("Table Scan (db.a)").getInfoString("data_files")); Assertions.assertEquals("5", group.getChildMap().get("Table Scan (db.b)").getInfoString("data_files")); } - - @Test - public void groupNameConstantsMatchConnectorLiterals() { - // The connector-supplied group name is stringly-typed coupled to these fe-core constants (the connector - // cannot import SummaryProfile). This is the fe-core half of the mirror check; the connector tests - // assert their own literals equal the same strings. - Assertions.assertEquals("Paimon Scan Metrics", SummaryProfile.PAIMON_SCAN_METRICS); - Assertions.assertEquals("Iceberg Scan Metrics", SummaryProfile.ICEBERG_SCAN_METRICS); - } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeVerboseExplainTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeVerboseExplainTest.java index 0d7d7c4b9dca62..bdce7d3e3ae97c 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeVerboseExplainTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeVerboseExplainTest.java @@ -53,7 +53,7 @@ * cut-over MaxCompute VERBOSE EXPLAIN (legacy {@code MaxComputeScanNode extends FileQueryScanNode} inherited * the unconditional block) and (b) violated the project rule that the generic SPI node must not branch on a * connector source name. After cut-over, jdbc/es/trino-connector/max_compute/paimon all route through this - * node ({@code SPI_READY_TYPES}); the block must appear for all of them.

      + * node; the block must appear for all of them.

      * *

      MUTATION killed: re-introducing {@code && "paimon".equals(desc.getTable().getDatabase() * .getCatalog().getType())} makes a non-paimon catalog (here {@code max_compute}) skip the block, so diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitPartitionValuesTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitPartitionValuesTest.java index be6ad2a84bdeac..0aa18bf0f55d82 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitPartitionValuesTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitPartitionValuesTest.java @@ -18,7 +18,6 @@ package org.apache.doris.datasource.split; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -42,11 +41,6 @@ public class PluginDrivenSplitPartitionValuesTest { private static ConnectorScanRange range(Map partitionValues, boolean partitionBearing) { return new ConnectorScanRange() { - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Map getProperties() { return Collections.emptyMap(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitWeightTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitWeightTest.java index acf3b09f36b210..73334b0c32ff33 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitWeightTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitWeightTest.java @@ -18,7 +18,6 @@ package org.apache.doris.datasource.split; import org.apache.doris.connector.api.scan.ConnectorScanRange; -import org.apache.doris.connector.api.scan.ConnectorScanRangeType; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -40,14 +39,9 @@ */ public class PluginDrivenSplitWeightTest { - /** Minimal fake range exposing only the two weight getters (+ the two required methods). */ + /** Minimal fake range exposing only the two weight getters (+ the one required method). */ private static ConnectorScanRange range(long selfWeight, long targetSize) { return new ConnectorScanRange() { - @Override - public ConnectorScanRangeType getRangeType() { - return ConnectorScanRangeType.FILE_SCAN; - } - @Override public Map getProperties() { return Collections.emptyMap(); 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 315f83b11e7f67..dbaf7e296d113b 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 @@ -84,7 +84,7 @@ public void insertGateAllowsConnectorDeclaringInsert() { @Test public void insertGateRejectsConnectorNotDeclaringInsert() { - // {} mirrors the null-write-provider connector's delegator view: Connector.supportedWriteOperations() + // {} mirrors the null-write-provider connector's view: the resolved provider's supportedOperations() // is empty whenever getWritePlanProvider() returns null. The gate must reject before ever resolving a // write plan provider / calling planWrite. PlanTranslatorContext context = new PlanTranslatorContext(); @@ -150,9 +150,9 @@ private static PluginDrivenExternalTable pluginTable(Set ops) { Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); // Production selects the write provider per-handle; a plain mock does not run the interface default. Mockito.when(connector.getWritePlanProvider(Mockito.any())).thenReturn(provider); - Mockito.when(connector.supportedWriteOperations()).thenReturn(ops); - // The admission gate now resolves the handle first and consults the per-handle overload. - Mockito.when(connector.supportedWriteOperations(Mockito.any())).thenReturn(ops); + // The admission gate resolves the handle, fetches the per-handle provider and asks IT which + // operations are supported -- the provider is the only place a write trait is declared. + Mockito.when(provider.supportedOperations()).thenReturn(ops); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) .thenReturn(Optional.of(handle)); 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 abba471f0af722..b5dd0d3dc2520b 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 @@ -22,12 +22,16 @@ import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.catalog.Column; import org.apache.doris.catalog.PrimitiveType; +import org.apache.doris.catalog.StructField; +import org.apache.doris.catalog.StructType; +import org.apache.doris.catalog.Type; import org.apache.doris.common.jmockit.Deencapsulation; import org.apache.doris.connector.api.Connector; 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.handle.ConnectorTableHandle; import org.apache.doris.connector.api.handle.WriteOperation; import org.apache.doris.connector.api.mvcc.ConnectorMvccSnapshot; @@ -57,6 +61,7 @@ import java.util.Collections; import java.util.EnumSet; import java.util.List; +import java.util.Locale; import java.util.Optional; /** @@ -232,6 +237,43 @@ public void rowLevelDmlThreadsMvccReadSnapshotPinOntoTheWriteHandle() { + " latest-read handle"); } + @Test + public void rowLevelDmlConnectorColumnsCarryTheWholeTypeNotJustItsPrimitiveTag() { + // Production ALWAYS threads the hidden __DORIS_ICEBERG_ROWID_COL__ -- a STRUCT (file_path, pos, ...) + // -- through a row-level DML sink's cols, so this arm sees a complex type on every DELETE/UPDATE/MERGE, + // even against a table whose data columns are all scalar. Deriving the ConnectorType from the column's + // primitive TAG alone ("STRUCT") drops the children, and a childless complex type is rejected outright + // by ConnectorType's shape check -> the whole iceberg DML surface failed to translate (build 1006199). + // The other tests here use a scalar-only fixture, which is exactly why they could not catch it. + Column rowId = new Column(Column.ICEBERG_ROWID_COL, new StructType( + new StructField("file_path", Type.STRING), + new StructField("pos", Type.BIGINT))); + + PlanFragment childFragment = Mockito.mock(PlanFragment.class); + Plugin plugin = pluginTable(); + + @SuppressWarnings("unchecked") + PhysicalExternalRowLevelDeleteSink sink = Mockito.mock(PhysicalExternalRowLevelDeleteSink.class); + Mockito.doReturn(mockChild(childFragment)).when(sink).child(); + Mockito.doReturn(plugin.table).when(sink).getTargetTable(); + Mockito.doReturn(ImmutableList.of(DATA, rowId)).when(sink).getCols(); + + PlanTranslatorContext context = new PlanTranslatorContext(); + PhysicalPlanTranslator translator = new PhysicalPlanTranslator(context, null); + translator.visitPhysicalExternalRowLevelDeleteSink(sink, context); + + @SuppressWarnings("unchecked") + List connectorColumns = (List) Deencapsulation.getField( + capturePluginSink(childFragment), "connectorColumns"); + ConnectorType rowIdType = connectorColumns.get(1).getType(); + Assertions.assertEquals("STRUCT", rowIdType.getTypeName().toUpperCase(Locale.ROOT), + "the row-id column must keep its STRUCT tag"); + Assertions.assertEquals(ImmutableList.of("file_path", "pos"), rowIdType.getFieldNames(), + "the row-id STRUCT must carry its field names, not be flattened to a bare tag"); + Assertions.assertEquals(2, rowIdType.getChildren().size(), + "the row-id STRUCT must carry its child types, not be flattened to a bare tag"); + } + // ==================== helpers ==================== /** A column-less slot (no backing Column, so its slot col_name is empty until the loop materializes it). */ @@ -271,13 +313,9 @@ private static Plugin pluginTable() { Mockito.when(connector.getWritePlanProvider()).thenReturn(provider); // Production selects the write provider per-handle; a plain mock does not run the interface default. Mockito.when(connector.getWritePlanProvider(Mockito.any())).thenReturn(provider); - // The row-level DML gate (buildPluginRowLevelDmlSink) admits on connector.supportedWriteOperations() - // containing DELETE/MERGE. On a plain mock the Connector delegator default is not invoked, so stub it - // directly (an iceberg connector declares row-level DML support). - Mockito.when(connector.supportedWriteOperations()) - .thenReturn(EnumSet.of(WriteOperation.DELETE, WriteOperation.MERGE)); - // Site 1 (row-level DML) now resolves the handle first and consults the per-handle overload. - Mockito.when(connector.supportedWriteOperations(Mockito.any())) + // The row-level DML gate (buildPluginRowLevelDmlSink) resolves the handle, fetches the per-handle + // provider and admits on ITS supportedOperations containing DELETE/MERGE. + Mockito.when(provider.supportedOperations()) .thenReturn(EnumSet.of(WriteOperation.DELETE, WriteOperation.MERGE)); Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata); Mockito.when(metadata.getTableHandle(Mockito.any(), Mockito.any(), Mockito.any())) diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateTableCommandTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateTableCommandTest.java index c322845f7b155c..eda1f43a42c873 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateTableCommandTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/CreateTableCommandTest.java @@ -761,46 +761,28 @@ public void testCreateTablePartitionForExternalCatalog() { // NOTE: the iceberg DISTRIBUTE BY rejection moved off fe-core into IcebergConnectorMetadata.createTable // (SPI cutover); it is covered by fe-connector-iceberg IcebergCreateTableValidationTest. - par = getCreateTableStmt("create table tb1 (id int not null, id2 int not null, id3 int not null) " - + "ENGINE=iceberg partition by (id, func1(id2, 1), func(3,id1), id3) () properties (\"a\"=\"b\")"); + // Writing ENGINE=iceberg while sitting in the internal catalog used to be how a test reached the + // external analysis arm: the nine-name whitelist accepted the name wherever it was written, and the + // statement only failed at execution. The target catalog now judges the name, so that shortcut is a + // rejection -- assert it, and exercise the external partition conversion where it actually lives. + AnalysisException wrongTarget = Assertions.assertThrows(AnalysisException.class, + () -> getCreateTableStmt("create table tb1 (id int) ENGINE=iceberg properties (\"a\"=\"b\")")); + Assertions.assertEquals("Engine 'iceberg' does not match catalog 'internal'.", wrongTarget.getMessage()); + + par = externalPartitionDesc("create table tb1 (id int not null, id2 int not null, id3 int not null) " + + "partition by (id, func1(id2, 1), func(3,id1), id3) () properties (\"a\"=\"b\")"); Assertions.assertEquals( "PARTITION BY LIST(`id`, func1(`id2`, '1'), func('3', `id1`), `id3`)\n" + "(\n" + "\n" + ")", par.toSql()); - try { - getCreateTableStmt( - "create table tb1 (id int) " - + "engine = iceberg rollup (ab (cd)) properties (\"a\"=\"b\")"); - } catch (Exception e) { - Assertions.assertEquals( - "iceberg catalog doesn't support rollup tables.", - e.getMessage()); - } - - try { - getCreateTableStmt("create table tb1 (id int) engine = hive rollup (ab (cd)) properties (\"a\"=\"b\")"); - } catch (Exception e) { - Assertions.assertEquals( - "hive catalog doesn't support rollup tables.", - e.getMessage()); - } - // test with empty partitions - LogicalPlan plan = new NereidsParser().parseSingle( - "create table tb1 (id int) engine = iceberg properties (\"a\"=\"b\")"); - Assertions.assertTrue(plan instanceof CreateTableCommand); - CreateTableInfo createTableInfo = ((CreateTableCommand) plan).getCreateTableInfo(); - createTableInfo.validate(connectContext); - Assertions.assertNull(createTableInfo.getPartitionDesc()); + Assertions.assertNull( + externalPartitionDesc("create table tb1 (id int) properties (\"a\"=\"b\")"), + "no partition clause must convert to no partition descriptor"); // test with multi partitions - LogicalPlan plan2 = new NereidsParser().parseSingle( - "create table tb1 (id int) engine = iceberg " + PartitionDesc partitionDesc2 = externalPartitionDesc("create table tb1 (id int) " + "partition by (val, bucket(2, id), par, day(ts), efg(a,b,c)) () properties (\"a\"=\"b\")"); - Assertions.assertTrue(plan2 instanceof CreateTableCommand); - CreateTableInfo createTableInfo2 = ((CreateTableCommand) plan2).getCreateTableInfo(); - createTableInfo2.validate(connectContext); - PartitionDesc partitionDesc2 = createTableInfo2.getPartitionDesc(); List partitionFields2 = partitionDesc2.getPartitionExprs(); Assertions.assertEquals(5, partitionFields2.size()); @@ -847,6 +829,18 @@ public void testCreateTablePartitionForExternalCatalog() { partitionDesc2.toSql()); } + /** + * The partition descriptor an EXTERNAL target produces. Exercised directly rather than through a + * CREATE TABLE aimed at the internal catalog: {@code isExternal} is now derived from the target catalog, + * so an engine name can no longer stand in for one. + */ + private PartitionDesc externalPartitionDesc(String sql) { + LogicalPlan plan = new NereidsParser().parseSingle(sql); + Assertions.assertTrue(plan instanceof CreateTableCommand); + CreateTableInfo info = ((CreateTableCommand) plan).getCreateTableInfo(); + return info.getPartitionTableInfo().convertToPartitionDesc(true); + } + private PartitionDesc getCreateTableStmt(String sql) { LogicalPlan plan = new NereidsParser().parseSingle(sql); Assertions.assertTrue(plan instanceof CreateTableCommand); 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 b45ca6b3b0e4ad..ce6acf86d2667b 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 @@ -91,7 +91,7 @@ private Plan filterOver(TableIf table, String columnName) { /** * A {@link PluginDrivenExternalTable} whose connector reports the given row-level DML capabilities. * Mirrors {@code InsertOverwriteTableCommandTest.pluginTable} — the established way to exercise the - * {@code getConnector().supportedWriteOperations()} probe. + * {@code getConnector().getWritePlanProvider(handle).supportedOperations()} probe. */ private static PluginDrivenExternalTable pluginTable(boolean supportsDelete, boolean supportsMerge) { PluginDrivenExternalTable table = Mockito.mock(PluginDrivenExternalTable.class); 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 3bfb588129443c..25ae3b28ecc1aa 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 @@ -41,7 +41,6 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; -import java.util.EnumSet; import java.util.List; import java.util.Optional; @@ -135,9 +134,11 @@ public void testHandlerEmitsFiveColumnsWhenConnectorDeclaresPartitionStats() thr 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). - Mockito.when(connector.getCapabilities()) - .thenReturn(EnumSet.of(ConnectorCapability.SUPPORTS_PARTITION_STATS)); + // The capability that flips SHOW PARTITIONS to the 5-column rich result (D-045). Stubbed on the + // CATALOG because that is the collaborator the command asks; the accessor's own null-safe + // derivation from the connector is pinned by PluginDrivenMvccTableFactoryTest. + Mockito.when(catalog.hasConnectorCapability(ConnectorCapability.SUPPORTS_PARTITION_STATS)) + .thenReturn(true); Mockito.when(metadata.getTableHandle(session, "remote_db", "remote_tbl")) .thenReturn(Optional.of(handle)); Mockito.when(metadata.listPartitions(Mockito.eq(session), Mockito.eq(handle), Mockito.any())) 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 0111ef24649008..83a108792dbefc 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 @@ -231,13 +231,16 @@ public void executeSingleCallActionRejectsWhereCondition() { public void executeDistributedActionLowersWhereAndThreadsItToPlanRewrite() throws Exception { // The DISTRIBUTED arm lowers the (unbound) WHERE to a neutral ConnectorPredicate and threads it to the // connector's planRewrite — it must NOT reject it, and it must NOT pass null. Stub planRewrite to return - // no groups so the driver returns the all-zero row without opening a transaction. + // no groups so the driver takes the early return without opening a transaction, and stub the connector's + // result rendering (the result SHAPE is the connector's, so the mock must supply it). Fixture f = new Fixture(); Mockito.when(f.procedureOps.getExecutionMode("rewrite_data_files")) .thenReturn(ProcedureExecutionMode.DISTRIBUTED); Mockito.when(f.procedureOps.planRewrite(Mockito.any(), Mockito.any(), Mockito.anyString(), Mockito.anyMap(), Mockito.any(), Mockito.anyList())) .thenReturn(Collections.emptyList()); + Mockito.when(f.procedureOps.buildRewriteResult(Mockito.anyString(), Mockito.any())) + .thenReturn(rewriteResult("0", "0", "0", "0")); Expression where = new GreaterThan(new UnboundSlot("a"), new IntegerLiteral(5)); ConnectorExecuteAction action = new ConnectorExecuteAction("rewrite_data_files", @@ -253,7 +256,7 @@ public void executeDistributedActionLowersWhereAndThreadsItToPlanRewrite() throw Assertions.assertInstanceOf(ConnectorComparison.class, lowered.getExpression()); Assertions.assertEquals("a", ((ConnectorColumnRef) ((ConnectorComparison) lowered.getExpression()) .getLeft()).getColumnName()); - // No groups -> the legacy all-zero four-column rewrite result row. + // The engine passes the connector's rendered rows straight through to the ResultSet. Assertions.assertEquals(Collections.singletonList(Arrays.asList("0", "0", "0", "0")), rs.getResultRows()); } @@ -397,13 +400,16 @@ public void executeRefreshesTableCachesAfterSuccessfulSingleCall() throws Except @Test public void executeRefreshesTableCachesAfterSuccessfulDistributedRewrite() throws Exception { // The DISTRIBUTED rewrite also produces a new snapshot, so it must refresh too. No groups -> the driver - // returns the all-zero row without opening a transaction, but the refresh still runs on a normal return. + // takes the early return without opening a transaction, but the refresh still runs on a normal return. Fixture f = new Fixture(); Mockito.when(f.procedureOps.getExecutionMode("rewrite_data_files")) .thenReturn(ProcedureExecutionMode.DISTRIBUTED); Mockito.when(f.procedureOps.planRewrite(Mockito.any(), Mockito.any(), Mockito.anyString(), Mockito.anyMap(), Mockito.any(), Mockito.anyList())) .thenReturn(Collections.emptyList()); + // Without this the mock returns null and the engine NPEs while wrapping the result. + Mockito.when(f.procedureOps.buildRewriteResult(Mockito.anyString(), Mockito.any())) + .thenReturn(rewriteResult("0", "0", "0", "0")); Expression where = new GreaterThan(new UnboundSlot("a"), new IntegerLiteral(5)); ConnectorExecuteAction action = new ConnectorExecuteAction("rewrite_data_files", @@ -476,6 +482,20 @@ public void validateEnforcesAlterPrivilege() { // -------- helpers -------- + /** + * Stands in for what the CONNECTOR renders for a distributed rewrite. The engine no longer owns those + * columns, so a mock procedureOps has to supply them; without a stub the mock returns null and the engine + * NPEs while wrapping the result. Shape only — the real names/types are pinned in the connector's own test. + */ + private static ConnectorProcedureResult rewriteResult(String... row) { + List schema = Arrays.asList( + new ConnectorColumn("rewritten_data_files_count", ConnectorType.of("INT"), "", false, null), + new ConnectorColumn("added_data_files_count", ConnectorType.of("INT"), "", false, null), + new ConnectorColumn("rewritten_bytes_count", ConnectorType.of("INT"), "", false, null), + new ConnectorColumn("removed_delete_files_count", ConnectorType.of("BIGINT"), "", false, null)); + return new ConnectorProcedureResult(schema, Collections.singletonList(Arrays.asList(row))); + } + private static ConnectorProcedureResult twoColumnResult(List row) { List schema = Arrays.asList( new ConnectorColumn("previous_snapshot_id", ConnectorType.of("BIGINT"), "prev", false, null), 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 9a95ae23067e60..2ec1be416b2031 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 @@ -27,6 +27,7 @@ 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.procedure.ConnectorRewriteStatistics; import org.apache.doris.connector.api.pushdown.ConnectorColumnRef; import org.apache.doris.connector.api.pushdown.ConnectorPredicate; import org.apache.doris.datasource.ExternalTable; @@ -41,14 +42,17 @@ import java.util.Arrays; import java.util.Collections; -import java.util.List; import java.util.Set; -import java.util.stream.Collectors; /** * Guards the engine-neutral parts of {@link ConnectorRewriteDriver} that are unit-testable without a live - * cluster: the empty-plan early return (no transaction opened, all-zero row) and the connector-failure - * mapping. The full distributed write path (N INSERT-SELECTs against BE) is exercised at the flip rehearsal. + * cluster: the empty-plan early return (no transaction opened), what the driver reports to the connector, and + * the connector-failure mapping. The full distributed write path (N INSERT-SELECTs against BE) is exercised at + * the flip rehearsal. + * + *

      The RESULT SHAPE is deliberately not asserted here any more: the columns belong to the connector that + * declares the procedure, and are pinned by {@code IcebergProcedureOpsTest}. What the engine owes is asserted + * below — it reports the right numbers and returns the connector's result untouched.

      */ public class ConnectorRewriteDriverTest { @@ -73,24 +77,30 @@ private ConnectorRewriteDriver driverWith(ConnectorProcedureOps procedureOps, Co } @Test - public void emptyPlanReturnsZeroRowWithoutOpeningTransaction() throws Exception { + public void emptyPlanReportsAllZerosWithoutOpeningTransaction() throws Exception { ConnectorProcedureOps procedureOps = Mockito.mock(ConnectorProcedureOps.class); ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class); Mockito.when(procedureOps.planRewrite(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(Collections.emptyList()); + ConnectorProcedureResult rendered = new ConnectorProcedureResult( + Collections.singletonList(new ConnectorColumn("c", ConnectorType.of("INT"), "", false, null)), + Collections.singletonList(Collections.singletonList("0"))); + Mockito.when(procedureOps.buildRewriteResult(Mockito.any(), Mockito.any())).thenReturn(rendered); ConnectorProcedureResult result = driverWith(procedureOps, metadata).run(); - // Four-column schema in the exact legacy order and types. - List schema = result.getResultSchema(); - Assertions.assertEquals(Arrays.asList( - "rewritten_data_files_count", "added_data_files_count", - "rewritten_bytes_count", "removed_delete_files_count"), - schema.stream().map(ConnectorColumn::getName).collect(Collectors.toList())); - Assertions.assertEquals(Arrays.asList("INT", "INT", "INT", "BIGINT"), - schema.stream().map(c -> c.getType().getTypeName()).collect(Collectors.toList())); - // Single all-zero row: nothing to rewrite. - Assertions.assertEquals(Collections.singletonList(Arrays.asList("0", "0", "0", "0")), result.getRows()); + // Nothing to rewrite: the connector is still asked to render, and it is told so with four zeros. + ArgumentCaptor captor = + ArgumentCaptor.forClass(ConnectorRewriteStatistics.class); + Mockito.verify(procedureOps).buildRewriteResult(Mockito.eq("rewrite_data_files"), captor.capture()); + ConnectorRewriteStatistics stats = captor.getValue(); + Assertions.assertEquals(0, stats.getDataFileCount()); + Assertions.assertEquals(0, stats.getAddedDataFileCount()); + Assertions.assertEquals(0L, stats.getTotalSizeBytes()); + Assertions.assertEquals(0, stats.getDeleteFileCount()); + // The engine returns what the connector rendered, unmodified. MUTATION: any engine-side post-processing + // of the result (re-wrapping, re-typing, substituting a default) is killed here. + Assertions.assertSame(rendered, result); // MUTATION: dropping the empty-groups early return is killed — no transaction may be opened, and no // group work scheduled, when there is nothing to rewrite. The driver opens the txn via the per-handle // beginTransaction(session, handle) overload, so watch that one (the single-arg matcher would go @@ -106,6 +116,10 @@ public void whereConditionIsThreadedToPlanRewrite() throws Exception { Mockito.when(procedureOps.planRewrite(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(Collections.emptyList()); ConnectorPredicate where = new ConnectorPredicate(new ConnectorColumnRef("a", ConnectorType.of("INT"))); + // Stubbed only so run() does not return a null from the unstubbed mock; this test asserts nothing + // about the result. + Mockito.when(procedureOps.buildRewriteResult(Mockito.any(), Mockito.any())).thenReturn( + new ConnectorProcedureResult(Collections.emptyList(), Collections.emptyList())); driverWith(procedureOps, Mockito.mock(ConnectorMetadata.class), where).run(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java index 37ab1c620cc63f..c1d42a12ddd973 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java @@ -18,7 +18,9 @@ package org.apache.doris.nereids.trees.plans.commands.info; import org.apache.doris.catalog.Env; +import org.apache.doris.datasource.CatalogIf; import org.apache.doris.datasource.CatalogMgr; +import org.apache.doris.datasource.InternalCatalog; import org.apache.doris.datasource.plugin.PluginDrivenExternalCatalog; import org.apache.doris.nereids.exceptions.AnalysisException; import org.apache.doris.qe.ConnectContext; @@ -37,31 +39,27 @@ import java.util.HashMap; /** - * Tests engine-padding / catalog-engine-consistency in {@link CreateTableInfo} for a - * {@link PluginDrivenExternalCatalog}, the form a {@code max_compute} catalog takes after the - * SPI cutover (T06b). FIX-DDL-ENGINE (P4-T06d). + * Tests how {@link CreateTableInfo} settles the {@code ENGINE=} clause now that the engine holds no table of + * which engine name belongs to which data source. * - *

      Why these tests matter: {@code paddingEngineName} and {@code checkEngineWithCatalog} - * key on {@code instanceof MaxComputeExternalCatalog}; after cutover the catalog is a - * {@code PluginDrivenExternalCatalog} (type {@code "max_compute"}), so a no-ENGINE CREATE TABLE - * (the most common MC form) threw "Current catalog does not support create table" at analysis - * time and never reached the working {@code createTable} override. These tests lock in that the - * engine is padded to {@code maxcompute} (plain CREATE and CTAS), that the catalog-engine - * consistency check still rejects a wrong explicit ENGINE, and that the non-CREATE-TABLE SPI - * types (jdbc/es/trino) keep their legacy behavior.

      + *

      What changed and why these tests matter. Analysis used to run four engine-name gates: it padded a + * missing {@code ENGINE=} from a hardcoded catalog-type switch, checked the name against a nine-name + * whitelist, checked it against the same switch again, and gated {@code PARTITION BY} / {@code DISTRIBUTED BY} + * on per-engine allow-lists. All four are gone. What remains is one question asked of the resolved target + * catalog ({@link CatalogIf#validateCreateTableEngine}) and one boolean derived from it — is the target the + * internal catalog. A missing {@code ENGINE=} is padded only for the internal catalog, which still dispatches + * on it; for every other target the statement now carries no engine name at all, because nothing past analysis + * reads one.

      * - *

      Both gate methods re-fetch the catalog by name via - * {@code Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName)}, so the test catalog must be - * registered into a mocked {@link CatalogMgr} — a directly-constructed catalog would be ignored. - * The gate methods are private, so they are invoked reflectively.

      + *

      The gate re-fetches the catalog by name through + * {@code Env.getCurrentEnv().getCatalogMgr().getCatalog(ctlName)}, so a test catalog must be registered into a + * mocked {@link CatalogMgr} — a directly-constructed one would be ignored. The gate is private, so it is + * invoked reflectively.

      */ public class CreateTableInfoEngineCatalogTest { - // Mirror of CreateTableInfo.ENGINE_MAXCOMPUTE (private constant). - private static final String ENGINE_MAXCOMPUTE = "maxcompute"; - // Mirror of CreateTableInfo.ENGINE_HIVE (private constant) — the CREATE-TABLE engine a flipped - // hms catalog pads to. - private static final String ENGINE_HIVE = "hive"; + // Mirror of CreateTableInfo.ENGINE_OLAP, the one name the engine still resolves for itself. + private static final String ENGINE_OLAP = "olap"; private MockedStatic mockedEnv; private CatalogMgr catalogMgr; @@ -82,32 +80,49 @@ public void tearDown() { } } - /** Registers a PluginDriven catalog of the given connector type under the given name. */ - private void registerPluginCatalog(String ctlName, String type) { + /** + * Registers an external (plugin-driven) catalog that accepts exactly {@code acceptedEngine}, standing in + * for whatever its connector's provider declares. + */ + private PluginDrivenExternalCatalog registerExternalCatalog(String ctlName, String acceptedEngine) { PluginDrivenExternalCatalog catalog = Mockito.mock(PluginDrivenExternalCatalog.class); - Mockito.doReturn(type).when(catalog).getType(); + Mockito.doReturn(ctlName).when(catalog).getName(); + Mockito.doReturn(false).when(catalog).isInternalCatalog(); + try { + Mockito.doAnswer(invocation -> { + String written = invocation.getArgument(0); + if (!written.equals(acceptedEngine)) { + throw new org.apache.doris.common.AnalysisException( + CatalogIf.engineMismatchError(written, ctlName)); + } + return null; + }).when(catalog).validateCreateTableEngine(Mockito.anyString()); + } catch (Exception e) { + throw new IllegalStateException(e); + } + Mockito.when(catalogMgr.getCatalog(ctlName)).thenReturn(catalog); + return catalog; + } + + private void registerInternalCatalog(String ctlName) { + InternalCatalog catalog = Mockito.mock(InternalCatalog.class, Mockito.CALLS_REAL_METHODS); + Mockito.doReturn(ctlName).when(catalog).getName(); Mockito.when(catalogMgr.getCatalog(ctlName)).thenReturn(catalog); } private static CreateTableInfo newInfo(String ctlName, String engineName) { - return new CreateTableInfo(false, false, false, ctlName, "db", "tbl", + return newInfo(ctlName, engineName, false, false); + } + + private static CreateTableInfo newInfo(String ctlName, String engineName, boolean isExternal, boolean isTemp) { + return new CreateTableInfo(false, isExternal, isTemp, ctlName, "db", "tbl", new ArrayList<>(), new ArrayList<>(), engineName, null, new ArrayList<>(), null, null, null, new ArrayList<>(), new HashMap<>(), new HashMap<>(), new ArrayList<>()); } - private static void invokePadding(CreateTableInfo info, String ctlName) throws Throwable { - Method m = CreateTableInfo.class.getDeclaredMethod("paddingEngineName", String.class, ConnectContext.class); - m.setAccessible(true); - try { - m.invoke(info, ctlName, null); - } catch (InvocationTargetException e) { - throw e.getCause(); - } - } - - private static void invokeCheck(CreateTableInfo info) throws Throwable { - Method m = CreateTableInfo.class.getDeclaredMethod("checkEngineWithCatalog"); + private static void resolve(CreateTableInfo info) throws Throwable { + Method m = CreateTableInfo.class.getDeclaredMethod("resolveTargetCatalog"); m.setAccessible(true); try { m.invoke(info); @@ -117,148 +132,141 @@ private static void invokeCheck(CreateTableInfo info) throws Throwable { } @Test - public void noEnginePaddedToMaxcomputeForPluginDriven() throws Throwable { - registerPluginCatalog("mc_ctl", "max_compute"); - CreateTableInfo info = newInfo("mc_ctl", null); + public void noEngineOnExternalCatalogLeavesNoEngineName() throws Throwable { + registerExternalCatalog("ice_ctl", "iceberg"); + CreateTableInfo info = newInfo("ice_ctl", null); - invokePadding(info, "mc_ctl"); + resolve(info); - // Why: a no-ENGINE CREATE TABLE under a cutover max_compute catalog must auto-pad the - // legacy engine name, exactly as legacy MaxComputeExternalCatalog did, instead of throwing - // "Current catalog does not support create table". - Assertions.assertEquals(ENGINE_MAXCOMPUTE, info.getEngineName(), - "no-ENGINE CREATE TABLE on a PluginDriven max_compute catalog must pad engine=maxcompute"); + // The engine used to invent a name here from a catalog-type switch. Nothing past analysis reads one + // for an external target -- the connector request carries columns, partitioning, bucketing and + // properties -- so inventing one only created a table fe-core had to keep in sync with the connectors. + Assertions.assertNull(info.getEngineName(), + "a no-ENGINE CREATE TABLE on an external catalog must not have an engine name invented for it"); } @Test - public void ctasNoEnginePaddedToMaxcompute() { - registerPluginCatalog("mc_ctl", "max_compute"); - CreateTableInfo info = newInfo("mc_ctl", null); - - // CTAS routes through validateCreateTableAsSelect, whose first action is paddingEngineName. - // The downstream validate(ctx) is heavy and not exercised here; we assert only the padding - // side effect (set before validate runs). Pre-fix, paddingEngineName throws "does not support - // create table" before setting engineName, so getEngineName() would not be maxcompute. - try { - info.validateCreateTableAsSelect(Lists.newArrayList("mc_ctl"), new ArrayList<>(), - Mockito.mock(ConnectContext.class)); - } catch (Exception ignored) { - // Only the engine-padding side effect is under test here. - } + public void noEngineOnTheInternalCatalogStillPadsOlap() throws Throwable { + registerInternalCatalog("internal"); + CreateTableInfo info = newInfo("internal", null); + + resolve(info); - Assertions.assertEquals(ENGINE_MAXCOMPUTE, info.getEngineName(), - "CTAS into a PluginDriven max_compute catalog must pad engine=maxcompute via " - + "validateCreateTableAsSelect"); + // The internal catalog is the one target that still consumes the name: InternalCatalog.createTable + // dispatches on it. + Assertions.assertEquals(ENGINE_OLAP, info.getEngineName(), + "a no-ENGINE CREATE TABLE on the internal catalog must still be padded to olap"); } @Test - public void wrongExplicitEngineRejectedForPluginDriven() { - registerPluginCatalog("mc_ctl", "max_compute"); - CreateTableInfo info = newInfo("mc_ctl", "hive"); - - // Why: the catalog-engine consistency check must still reject a mismatched explicit ENGINE - // under PluginDriven (legacy MaxComputeExternalCatalog rejected ENGINE != maxcompute). This - // fails with no exception if the checkEngineWithCatalog PluginDriven branch is absent. - Assertions.assertThrows(AnalysisException.class, () -> invokeCheck(info), - "explicit ENGINE=hive on a PluginDriven max_compute catalog must be rejected"); + public void explicitEngineIsJudgedByTheTargetCatalogAndItsWordingReachesTheUser() { + registerExternalCatalog("ice_ctl", "iceberg"); + CreateTableInfo info = newInfo("ice_ctl", "jdbc"); + + AnalysisException ex = Assertions.assertThrows(AnalysisException.class, () -> resolve(info), + "an engine name the target catalog does not answer to must be rejected during analysis"); + // The catalog owns the wording; analysis only adapts the exception type. If it wrapped or reworded, + // every catalog would need fe-core's permission to phrase its own rejection. + Assertions.assertEquals(CatalogIf.engineMismatchError("jdbc", "ice_ctl"), ex.getMessage(), + "the target catalog's wording must reach the user verbatim"); } @Test - public void correctExplicitEnginePassesForPluginDriven() { - registerPluginCatalog("mc_ctl", "max_compute"); - CreateTableInfo info = newInfo("mc_ctl", ENGINE_MAXCOMPUTE); + public void acceptedExplicitEngineIsKept() throws Throwable { + registerExternalCatalog("ice_ctl", "iceberg"); + CreateTableInfo info = newInfo("ice_ctl", "iceberg"); - Assertions.assertDoesNotThrow(() -> invokeCheck(info), - "explicit ENGINE=maxcompute on a PluginDriven max_compute catalog must pass the check"); + resolve(info); + + Assertions.assertEquals("iceberg", info.getEngineName(), + "an engine name the catalog accepts must survive analysis unchanged"); } @Test - public void jdbcPluginDrivenStillUnsupported() { - registerPluginCatalog("jdbc_ctl", "jdbc"); - - // paddingEngineName: jdbc (helper returns null) falls through to the existing else-throw, - // byte-identical to legacy behavior for an SPI type that does not support CREATE TABLE. - CreateTableInfo padInfo = newInfo("jdbc_ctl", null); - AnalysisException ex = Assertions.assertThrows(AnalysisException.class, - () -> invokePadding(padInfo, "jdbc_ctl"), - "no-ENGINE CREATE TABLE on a jdbc PluginDriven catalog must still be unsupported"); - Assertions.assertTrue(ex.getMessage() != null && ex.getMessage().contains("does not support create table"), - "jdbc PluginDriven catalog must reuse the existing 'does not support create table' message"); - - // checkEngineWithCatalog: jdbc (helper returns null) must NOT throw — legacy lets jdbc/es/trino - // pass the consistency check unconditionally (they are not in the legacy instanceof chain). - CreateTableInfo checkInfo = newInfo("jdbc_ctl", "jdbc"); - Assertions.assertDoesNotThrow(() -> invokeCheck(checkInfo), - "jdbc PluginDriven catalog must pass checkEngineWithCatalog (legacy pass-through parity)"); + public void theInternalCatalogRejectsAnExternalEngineName() { + registerInternalCatalog("internal"); + CreateTableInfo info = newInfo("internal", "hive"); + + // This used to survive the whole of analysis and fail only at execution, because the nine-name + // whitelist accepted hive regardless of where the statement was aimed. + AnalysisException ex = Assertions.assertThrows(AnalysisException.class, () -> resolve(info)); + Assertions.assertEquals(CatalogIf.engineMismatchError("hive", "internal"), ex.getMessage()); } - // --------------------------------------------------------------------------------------------- - // HMS cutover: a flipped hms external catalog is a PluginDrivenExternalCatalog (type "hms"). - // pluginCatalogTypeToEngine must map "hms" -> ENGINE_HIVE so a no-ENGINE CREATE pads engine=hive - // (legacy hms catalogs always create hive-engine tables) and the catalog-engine consistency check - // still rejects a non-hive explicit ENGINE. Class A (unreachable until "hms" enters - // SPI_READY_TYPES); getType() is mocked to "hms" to prove the switch entry without an actual flip. - // --------------------------------------------------------------------------------------------- - @Test - public void noEnginePaddedToHiveForPluginDrivenHms() throws Throwable { - registerPluginCatalog("hms_ctl", "hms"); - CreateTableInfo info = newInfo("hms_ctl", null); - - invokePadding(info, "hms_ctl"); - - // Why: a no-ENGINE CREATE TABLE under a flipped hms catalog must auto-pad the hive engine, - // exactly as legacy HMSExternalCatalog did (paddingEngineName :913-914), instead of throwing - // "Current catalog does not support create table". MUTATION: dropping the "hms" case -> - // pluginCatalogTypeToEngine returns null -> throw -> this test fails. - Assertions.assertEquals(ENGINE_HIVE, info.getEngineName(), - "no-ENGINE CREATE TABLE on a PluginDriven hms catalog must pad engine=hive"); + public void theInternalCatalogKeepsAnsweringForItsOwnRetiredEngines() { + registerInternalCatalog("internal"); + for (String retired : new String[] {"odbc", "mysql", "broker"}) { + CreateTableInfo info = newInfo("internal", retired); + AnalysisException ex = Assertions.assertThrows(AnalysisException.class, () -> resolve(info), + retired + " is retired and must still be rejected"); + // Those three were the internal catalog's own table types, so it still owes the user the specific + // "use X instead" message rather than the generic mismatch. + Assertions.assertTrue(ex.getMessage().contains("no longer supported"), + "a retired internal engine must keep its own message, got: " + ex.getMessage()); + } } @Test - public void ctasNoEnginePaddedToHiveForHms() { - registerPluginCatalog("hms_ctl", "hms"); - CreateTableInfo info = newInfo("hms_ctl", null); + public void ctasResolvesTheTargetCatalogTheSameWay() { + registerInternalCatalog("internal"); + CreateTableInfo info = newInfo("internal", null); - // CTAS routes through validateCreateTableAsSelect, whose first action is paddingEngineName. - // The downstream validate(ctx) is heavy and not exercised here; assert only the padding side - // effect. Pre-fix, paddingEngineName throws before setting engineName. + // CTAS has its own prologue but must settle the engine through the same path; the heavy validate(ctx) + // that follows is not exercised here. try { - info.validateCreateTableAsSelect(Lists.newArrayList("hms_ctl"), new ArrayList<>(), + info.validateCreateTableAsSelect(Lists.newArrayList("internal"), new ArrayList<>(), Mockito.mock(ConnectContext.class)); } catch (Exception ignored) { - // Only the engine-padding side effect is under test here. + // Only the engine-resolution side effect is under test here. } - Assertions.assertEquals(ENGINE_HIVE, info.getEngineName(), - "CTAS into a PluginDriven hms catalog must pad engine=hive via validateCreateTableAsSelect"); + Assertions.assertEquals(ENGINE_OLAP, info.getEngineName(), + "CTAS into the internal catalog must resolve the engine the same way a plain CREATE does"); } @Test - public void wrongExplicitEngineRejectedForPluginDrivenHms() { - registerPluginCatalog("hms_ctl", "hms"); - // Legacy HMSExternalCatalog rejected any ENGINE != hive ("Hms type catalog can only use `hive` - // engine."); the flipped PluginDriven path mirrors that via checkEngineWithCatalog + the "hms" - // switch entry. An explicit iceberg engine on an hms catalog must be rejected. - CreateTableInfo info = newInfo("hms_ctl", "iceberg"); - - Assertions.assertThrows(AnalysisException.class, () -> invokeCheck(info), - "explicit ENGINE=iceberg on a PluginDriven hms catalog must be rejected"); + public void theExternalKeywordIsRejectedAgainstTheInternalCatalog() { + registerInternalCatalog("internal"); + CreateTableInfo info = newInfo("internal", null, true, false); + + // EXTERNAL used to be forced on by the engine-name whitelist and rejected only in the olap arm. It is + // now derived from the target, so the contradiction has to be caught explicitly or it would be + // silently overwritten -- and EXTERNAL is not cosmetic: it relaxes partition validation. + Assertions.assertThrows(AnalysisException.class, () -> resolve(info), + "CREATE EXTERNAL TABLE aimed at the internal catalog must still be rejected"); } @Test - public void correctExplicitEngineHivePassesForPluginDrivenHms() { - registerPluginCatalog("hms_ctl", "hms"); - CreateTableInfo info = newInfo("hms_ctl", ENGINE_HIVE); + public void externalTargetMakesTheStatementExternal() throws Throwable { + registerExternalCatalog("ice_ctl", "iceberg"); + CreateTableInfo info = newInfo("ice_ctl", null); + + resolve(info); - Assertions.assertDoesNotThrow(() -> invokeCheck(info), - "explicit ENGINE=hive on a PluginDriven hms catalog must pass the check"); + // isExternal reaches PartitionTableInfo.convertToPartitionDesc, where it turns on auto-partitioning. + // Deriving it from the target rather than from the engine name is what keeps transform partitioning + // working for a statement that never wrote ENGINE=. + Assertions.assertTrue(info.isExternal(), + "a statement aimed at an external catalog must be external even without the EXTERNAL keyword"); } - // NOTE: the iceberg v3 effective-format-version derivation + reserved-row-lineage-column rejection moved - // off fe-core CreateTableInfo into the iceberg connector (IcebergSchemaBuilder.getEffectiveFormatVersion + - // IcebergConnectorMetadata.createTable). The catalog-level table-default/override.format-version precedence - // is now covered by IcebergConnectorMetadataDdlTest; the former reflective tests that drove the deleted - // CreateTableInfo.getEffectiveIcebergFormatVersion / validateIcebergRowLineageColumns were removed with - // those methods. + @Test + public void temporaryTableIsRejectedOnAnExternalCatalog() { + registerExternalCatalog("ice_ctl", "iceberg"); + CreateTableInfo info = newInfo("ice_ctl", null, false, true); + + Assertions.assertThrows(AnalysisException.class, () -> resolve(info), + "temporary tables exist only in the internal catalog"); + } + + @Test + public void unknownCatalogIsReportedBeforeAnythingElse() { + Mockito.when(catalogMgr.getCatalog("nope")).thenReturn(null); + CreateTableInfo info = newInfo("nope", "iceberg"); + + AnalysisException ex = Assertions.assertThrows(AnalysisException.class, () -> resolve(info)); + Assertions.assertTrue(ex.getMessage().contains("Unknown catalog"), + "an unknown catalog must be named as such, not answered with an engine complaint"); + } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java index 897bfaf7b234ab..154bedf4e446f4 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkBindingTest.java @@ -69,7 +69,7 @@ public void overwriteAndStaticPartitionFlowToWriteHandle() throws AnalysisExcept Assertions.assertNotNull(handle, "planWrite must be invoked with a bound write handle"); Assertions.assertTrue(handle.isOverwrite(), "INSERT OVERWRITE must propagate ctx.isOverwrite()=true to the connector write handle"); - Assertions.assertEquals(staticSpec, handle.getWriteContext(), + Assertions.assertEquals(staticSpec, handle.getStaticPartitionSpec(), "PARTITION(col=val) must propagate the static partition spec to the write handle"); } @@ -84,7 +84,7 @@ public void absentContextDefaultsToNonOverwriteEmptySpec() throws AnalysisExcept Assertions.assertNotNull(handle); Assertions.assertFalse(handle.isOverwrite(), "a plain INSERT must default the connector write handle to non-overwrite"); - Assertions.assertTrue(handle.getWriteContext().isEmpty(), + Assertions.assertTrue(handle.getStaticPartitionSpec().isEmpty(), "a plain INSERT must pass an empty static partition spec"); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkTest.java index 930006cdff8878..a74d87b35829b3 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/PluginDrivenTableSinkTest.java @@ -101,7 +101,7 @@ public void bindDataSinkDelegatesToWritePlanProvider() throws AnalysisException Assert.assertSame(tableHandle, provider.seenHandle.getTableHandle()); Assert.assertSame(columns, provider.seenHandle.getColumns()); Assert.assertFalse(provider.seenHandle.isOverwrite()); - Assert.assertTrue(provider.seenHandle.getWriteContext().isEmpty()); + Assert.assertTrue(provider.seenHandle.getStaticPartitionSpec().isEmpty()); // No engine-built write sort by default -> the handle carries no sort info. Assert.assertNull(provider.seenHandle.getSortInfo()); } diff --git a/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProperties.java b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProperties.java index 532081b701baec..6d49bbcd06c2a1 100644 --- a/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProperties.java +++ b/fe/fe-filesystem/fe-filesystem-hdfs/src/main/java/org/apache/doris/filesystem/hdfs/HdfsFileSystemProperties.java @@ -42,7 +42,7 @@ * Provider-owned typed properties for HDFS / HDFS-compatible filesystems (hdfs, viewfs, ofs, jfs, oss-hdfs). * *

      This is the typed backend model for HDFS: it implements {@link BackendStorageProperties} so the - * typed pipeline ({@code ConnectorContext.getStorageProperties().toBackendProperties().toMap()}) can + * typed pipeline ({@code ConnectorStorageContext.getStorageProperties().toBackendProperties().toMap()}) can * re-produce the HDFS backend key set ({@code fs.defaultFS}, {@code dfs.*} HA, {@code hadoop.security.*} * + Kerberos principal/keytab, {@code hadoop.username}, ...) that the BE turns into {@code THdfsParams}. * Without it the typed path returns nothing for HDFS-warehouse catalogs (see DV-004 / R-007). diff --git a/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 b/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 index b300b6185c8730..9dfd4630ed8890 100644 --- a/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 +++ b/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 @@ -802,7 +802,6 @@ alterTableClause (BUCKETS (INTEGER_VALUE | autoBucket=AUTO))?)? #modifyDistributionClause | MODIFY COMMENT comment=STRING_LITERAL #modifyTableCommentClause | MODIFY COLUMN name=qualifiedName COMMENT comment=STRING_LITERAL #modifyColumnCommentClause - | MODIFY ENGINE TO name=identifier properties=propertyClause? #modifyEngineClause | ADD TEMPORARY? PARTITIONS FROM from=partitionValueList TO to=partitionValueList INTERVAL INTEGER_VALUE unit=identifier? properties=propertyClause? #alterMultiPartitionClause diff --git a/plan-doc/connector-api-spi-design-review-2026-07-25.md b/plan-doc/connector-api-spi-design-review-2026-07-25.md new file mode 100644 index 00000000000000..1d88e563ed9860 --- /dev/null +++ b/plan-doc/connector-api-spi-design-review-2026-07-25.md @@ -0,0 +1,877 @@ +# 连接器公共接口(fe-connector-api / fe-connector-spi)设计评审 + +调研日期:2026-07-25 +调研范围:`fe/fe-connector/fe-connector-api`、`fe/fe-connector/fe-connector-spi` 的全部接口与数据结构, +以及 8 个连接器实现(es / hive / hudi / iceberg / jdbc / maxcompute / paimon / trino)和 fe-core 插件驱动路径的对应调用点。 +本文只做分析和建议,不改动任何代码。 + +--- + +## 〇、复核说明(2026-07-26 追加) + +**这份文档是一次设计评审的快照,不是"当前接口现状"的描述。** + +- **调研基线**:`7ff51a106f0`(分支 `catalog-spi-review-19`)。正文的行号、方法数、"谁实现了什么"全部以那一刻为准。 +- **复核基线**:`53a753e0a1b`,距调研基线 **44 个提交**。这 44 个提交里有相当一部分正是本文建议的落地。 +- 同一天另有一轮**独立重做**的审查,产出 `connector-public-interface-cleanup/audit-report.md` 及其 `tasks/` 目录。两份并存而不是互相覆盖:两轮独立结论**一致的部分**本身就是可信度最强的证据,**分歧的部分**才是需要人拍板的地方。逐条交叉核对见那份报告的附录 C。 + +复核对本文只做三类改动,其余一字未动: + +1. **状态戳**(`> **状态(复核 …)**:…`):标注该节描述的问题已经落地、或已经失去对象。**分析正文原样保留**——"当时看到了什么、为什么这么判断"才是这份文档的资产。 +2. **交叉核对修正**(`> **交叉核对修正**:…`):回到代码实测后**不成立**的事实。原句同样保留,修正紧随其后。 +3. **就地重算**:第二节的两张表、以及正文里出现的具体方法数与构造函数数,直接换成复核日的数字;第五节的行动清单按复核日重编。这两块是全文唯一会被人照着动手的部分。 + +**方法数的计数口径**(原文未声明,复核统一为):只数写在该接口体内的方法声明;`default` 方法计入;**重载分别计**;注解不计;继承来的不计(需要连同继承一起看的地方单独标注)。 + +--- + +## 一、调研目的与判断标准 + +这两个模块是"公共契约层":fe-core 通过它们驱动所有外部数据源,连接器通过它们实现自己的能力。 +目标是让**新增一个连接器时,开发者只需要在自己的插件模块里写代码,不需要改 fe-core、也不需要改 api/spi 这两个公共模块**。 + +因此本文用四条标准去检查每一个接口: + +1. **中立性**:公共接口里不应该出现只有某一种数据源才成立的概念、名字或行为。 +2. **必要性**:每个方法都应该有真实的调用方;没人调用的方法是负债——它会让新连接器的作者以为"必须实现"。 +3. **单一职责**:一个接口应该只表达一件事。把互不相干的能力塞进同一个接口,会迫使实现者面对一大堆与自己无关的方法。 +4. **契约自洽**:接口文档写的规则,实现必须真的遵守;反过来,实现普遍采用的行为,文档不能写反。 + +--- + +## 二、现状概览 + +| 模块 | 内容 | 调研日(`7ff51a106f0`) | 复核日(`53a753e0a1b`) | +|---|---|---|---| +| `fe-connector-api` | 连接器要实现的业务接口 + 中立数据结构 | 95 个源文件,10149 行 | **101 个源文件,10978 行** | +| `fe-connector-spi` | 插件发现与引擎回调(`ConnectorProvider`、`ConnectorContext` 等) | 5 个源文件,646 行 | 5 个源文件,819 行 | + +> **就地重算**:原文这两格写的是"约 9800 行"与"约 600 行",实测偏低(文件数 95 是对的)。 +> +> **状态(复核 `53a753e0a1b`)**:值得注意的是,八批清理删掉了大量死接口面之后,`fe-connector-api` 反而**净增 6 个文件、829 行**——删掉的方法被写进去的契约文档抵消了。**任何"公共接口面在收缩"的论据都不成立**,收缩发生在"要实现的方法"上,不在代码行数上。 + +核心接口的方法规模: + +| 接口 | 调研日方法数 | 复核日方法数 | 说明 | +|---|---|---|---| +| `ConnectorTableOps` | 43 个方法名(46 个声明,含 3 组重载) | 家族合计 **43 个声明**(41 个方法名):聚合接口自身只剩 **2 个**,其余分布在 6 个域接口(13 / 4 / 4 / 11 / 7 / 2) | 表相关的一切(已按域拆分,见 E1 状态戳) | +| `Connector` | 32(实测应为 34) | **21**(18 个方法名) | 连接器总入口 | +| `ConnectorScanPlanProvider` | 24 | 23(20 个方法名) | 读路径规划 | +| `ConnectorContext` | 19 | 18(17 个方法名) | 引擎提供给连接器的服务 | +| `ConnectorSession` | 14(实测应为 15) | 15 | 会话上下文 | +| `ConnectorWritePlanProvider` | 12 | 12 | 写路径规划 | +| `ConnectorMetadata` | 11(自身)+ 继承 6 个子接口,合计约 70 | 自身 **10** + 继承 6 个子接口,家族合计 **75 个声明** | 元数据总入口 | + +> **交叉核对修正**:`Connector` 在调研日实测是 34 个方法(不是 32),`ConnectorSession` 实测是 15 个(不是 14)。 +> +> **状态(复核 `53a753e0a1b`)**:`Connector` 从 34 掉到 21,是三批删除叠加的结果——写特性镜像 11 个(`dfbefc790e8`)、属性描述两个 getter(`a7de939fba9`)、REST 直通换成判空探针(`1f2452165ec`),期间新增了 `ownsHandle` 与 `getRestPassthrough`。 + +需要先说明:这套接口有不少做得很好的地方——几乎所有方法都有默认实现(新连接器可以只实现自己支持的部分)、 +数据结构基本是中立的值对象、`ConnectorContext` 刻意避开了 Thrift 类型(用字符串和中立的 `ConnectorBrokerAddress` 传递)、 +每语句作用域(`ConnectorStatementScope`)把缓存生命周期交给引擎管理。本文聚焦的是仍然存在的问题。 + +--- + +## 三、问题总览 + +按"是否阻碍目标"排优先级: + +| 编号 | 问题 | 影响 | +|---|---|---| +| A | 新增连接器**仍然必须改 fe-core**(类型白名单 + 两处按类型的分支) | 直接违背目标 | +| B | 公共接口里混入了源专有语义(10 处) | 中立性破坏,新连接器要面对无意义的方法 | +| C | 存在无人调用的"死接口"(5 处) | 误导实现者,增加维护成本 | +| D | 能力声明有 5 套并存机制 | 新连接器不知道该用哪套 | +| E | 四个核心接口职责严重耦合 | 实现者要在几十个不相干方法里找自己需要的 | +| F | 一批语义不清的接口(单位/空值/命名) | 容易实现错,且错得很安静 | +| G | 对称性缺口:异构网关连接器有潜在错误 | 潜在缺陷 | +| H | 4 处实现与接口文档相互矛盾 | 契约不可信 | + +> **逐类状态(复核 `53a753e0a1b`)** +> +> | 编号 | 复核结论 | +> |---|---| +> | A | **大部分已解除**:类型白名单已删(A1),能力枚举未变且未构成障碍(A3),分片类型枚举已删(A4);**只剩 A2 那两处展示名 switch**,属已知未决 | +> | B | **10 处中:2 处已落地、1 处符号消失、2 处经复核不算违规(其中 1 处的建议照做会打断 iceberg 与 paimon)、1 处已中立化改名、其余仍在** | +> | C | **5 处死接口已删 4 处半**;唯一完整幸存的是 C2(`estimateScanRangeCount`),C6 的布尔位也还在 | +> | D | **5 套并存机制已减为 4 套**(CSV 那套已类型化),且选择规则已成文写进包级说明 | +> | E | **E1 已按域拆分、E3 已从 34 个方法减到 21 个**;E2、E4、E5 原样 | +> | F | **8 条中:1 条(`getProperties`)符号已删、1 条(`getWritePartitioning` 三态)经复核不成立、1 条(同名反向失效接口)冲突已随删除消失**;其余仍在 | +> | G | **G1 已落地、G3 的规则已写下(但两半未统一)**;G2 的前提在调研日就是错的,缺口比原文窄得多 | +> | H | **H2 作废、H3/H4 失去对象、H5 仍在**;**H1 仍然成立且升级为三方矛盾,是全文最该先做的一条** | + +--- + +## 四、问题详述 + +### A. 新增连接器仍然必须修改 fe-core + +这是最直接违背目标的一类问题,共 3 处。 + +#### A1. fe-core 里写死了"哪些类型走插件路径"的白名单 + +> **状态(复核 `53a753e0a1b`):已落地。** `SPI_READY_TYPES` 全仓零命中,白名单由 `23b971a4db9` 整体删除;路由改为先问插件注册表。本节建议的"反向名单"也已经存在:`CatalogFactory.BUILTIN_CATALOG_TYPES = {"lakesoul", "doris", "test"}` 配 `isBuiltinCatalogType`,且那三个内建类型名成为保留字——插件声明它们时在注册期就被拒绝。 + +`fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java:56` + +```java +private static final Set SPI_READY_TYPES = + ImmutableSet.of("jdbc", "es", "trino-connector", "max_compute", "paimon", "iceberg", "hms"); +``` + +只有出现在这个集合里的 catalog 类型才会去查找连接器插件(第 110 行)。 +也就是说:即使一个新连接器完整实现了 `ConnectorProvider` 并正确注册到 `META-INF/services`、 +插件包也放进了插件目录,**只要不在这行字符串里加上自己的类型名,`CREATE CATALOG` 就完全不会走插件路径**。 + +**背景**:这个白名单是迁移期的安全阀——迁移过程中同一个类型可能同时存在"老的内建实现"和"新的插件实现", +白名单保证只有已经验证过的类型才切到新路径。 + +**原因**:它把"这个类型有没有插件"(可以运行时发现)和"这个类型允不允许用插件"(人工决策)混在了一起。 + +**解决方向**:把判断反过来——先问插件注册表"有没有 provider 支持这个类型",有就走插件路径,没有再走内建分支。 +人工决策改由"是否把插件包放进插件目录"来表达,这本来就是插件机制天然的开关。 +迁移期若仍需保留强制内建的能力,可以改成一个**反向的**小名单("这些类型即使有插件也强制走内建"), +迁移完成后直接删掉,而不是每加一个连接器就改一次。 + +#### A2. fe-core 里按 catalog 类型做的两处 switch + +> **状态(复核 `53a753e0a1b`):仍然成立,且属于已知未决而不是遗漏。** 两处 switch 原样存在(符号未变,行号已漂到 `getEngine()` / `getEngineTableTypeName()` 各自的定义处)。后续有一轮专门中立化了另外两处按类型名匹配的引擎分支(`73d90807eaf`),审到这两处时判定**暂不下沉**——它们只影响对外展示名,不影响功能可达性。 + +`fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java:1275` 和 `1313` + +两个方法(`getEngine()` 与 `getEngineTableTypeName()`)都是对 catalog 类型字符串做 switch, +把 `"jdbc" / "es" / "iceberg" / "trino-connector" / "max_compute" / "paimon" / "hms"` 分别映射成对外展示的引擎名和表类型名。 +这两个值会出现在 `SHOW TABLE STATUS`、`information_schema.tables`、REST 接口里。 + +**背景**:这是为了兼容——迁移前每种外部表都是一个独立的 Java 类,有各自的展示名; +迁移后它们都变成同一个 `PluginDrivenExternalTable`,如果不 switch 就会统一显示成 `Plugin`,属于用户可见的行为回退。 + +**影响程度**:比 A1 轻。新连接器不加分支也能工作,只是展示名会落到默认值。 +但这仍然是"公共模块里按数据源名字分叉"的写法,与既定的架构原则冲突。 + +**解决方向**:把展示名变成连接器自己声明的东西。最小改动是在 `ConnectorProvider` 上加一个 +`default String getEngineDisplayName() { return getType(); }`,让 fe-core 直接取值; +已有的 7 个连接器各自返回它们的历史名字,switch 整体删除。这样新连接器不写也有合理默认值。 + +#### A3. 能力项是一个封闭枚举 + +`fe-connector-api/.../ConnectorCapability.java` + +`ConnectorCapability` 是一个 13 项的枚举。连接器通过 `Connector.getCapabilities()` 返回自己支持的集合。 +问题是:**任何一个新连接器只要需要一项现有枚举没覆盖的能力,就必须修改 `fe-connector-api`**。 + +这一点比 A1/A2 更微妙,因为"引擎要理解这项能力才能据此改变行为",所以能力项本身确实需要引擎认识。 +但目前这 13 项中有相当一部分(见下表)本质上是"某个具体连接器的行为开关",而不是通用能力: + +| 能力项 | 引擎侧真实用途 | 通用性 | +|---|---|---| +| `SUPPORTS_MVCC_SNAPSHOT` | 是否走快照读路径 | 通用 | +| `SUPPORTS_VIEW` | 是否把对象当视图 | 通用 | +| `SUPPORTS_PARTITION_STATS` | `SHOW PARTITIONS` 渲染几列 | 通用 | +| `SUPPORTS_PASSTHROUGH_QUERY` | `query()` 表函数 | 偏 JDBC | +| `SUPPORTS_SHOW_CREATE_DDL` | 是否渲染属性(防止 JDBC 泄漏密码) | 通用,但动机是安全兜底 | +| `SUPPORTS_TOPN_LAZY_MATERIALIZE` / `SUPPORTS_NESTED_COLUMN_PRUNE` | 优化器开关 | 通用(能力协商) | +| `SUPPORTS_METADATA_PRELOAD` | 是否预热元数据 | 通用(纯性能) | +| `SUPPORTS_USER_SESSION` | 是否注入用户凭证、是否绕开共享缓存 | 通用(安全) | +| `SUPPORTS_SORT_ORDER` | `CREATE TABLE ... ORDER BY` 是否被接受 | 通用(语法门禁) | +| `SUPPORTS_COLUMN_AUTO_ANALYZE` / `SUPPORTS_SAMPLE_ANALYZE` | 统计信息采集方式 | 通用 | +| `SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE` | 嵌套列 DDL 是否被接受 | 通用 | + +**结论**:枚举本身可以保留(引擎必须认识能力项),但需要**明确一条规则并写进枚举的类注释**: +只有"引擎必须据此改变自己行为"的开关才能进这个枚举; +只有连接器自己需要知道的东西一律不进。 +另外,从这些枚举项的注释里可以看出,绝大多数都是在替换迁移前的"按类名判断"逻辑, +所以这个枚举天然带有"历史包袱清单"的性质,值得在迁移收尾时重新审一遍哪些可以合并或删除。 + +#### A4. 另外两个封闭枚举的实际情况 + +- `ConnectorScanRangeType`(4 项):见 C1,引擎根本不读,属于死接口,删掉即可,不构成扩展障碍。 +- `ConnectorColumnCategory`(3 项):只有 3 个中立取值(默认 / 合成列 / 生成列),语义清晰,没有扩展压力,可以保留。 + +> **状态(复核 `53a753e0a1b`)**:`ConnectorScanRangeType` 已由 `da60bfadbac` 整族删除(见 C1 状态戳)。`ConnectorColumnCategory` 保持不变。A3 的 `ConnectorCapability` 枚举**仍是 13 项、一项没增没减**,本节结论未受这 44 个提交影响。 + +--- + +### B. 公共接口里混入了源专有语义 + +这类问题的共同表现是:接口的名字或语义只有在某一种数据源下才讲得通, +新连接器的作者读到它们时无法判断"我要不要实现"。 + +下表是逐条核对"某个方法在 8 个连接器里到底谁实现了"得到的结果(只列出真正有问题的): + +| 接口方法 | 实现者 | 问题性质 | +|---|---|---| +| `Connector.executeRestRequest(path, body)` (`Connector.java:303`) | 仅 es | HTTP REST 透传是 Elasticsearch 独有的访问方式,却挂在所有连接器的总入口上 | +| `ConnectorTableOps.executeStmt(session, stmt)` (`:432`) | 仅 jdbc | "直接执行一条 SQL 语句"假设远端是一个 SQL 数据库 | +| `ConnectorTableOps.getColumnsFromQuery(session, query)` (`:440`) | 仅 jdbc | 同上,依赖远端能对任意 SQL 做预编译取元数据 | +| `ConnectorTableOps.isPartitionValuesSysTable(...)` (`:88`) | 仅 hive | 方法名直接暴露了 fe-core 内部某个表函数的实现方式 | +| `ConnectorScanPlanProvider.getSerializedTable(nodeProps)` (`:520`) | 仅 paimon | 注释明写"目前用于 Paimon 把序列化的 Table 对象传给 BE" | +| `ConnectorScanPlanProvider.adjustFileCompressType(inferred)` (`:125`) | hive、hudi | 存在的唯一理由是 Hadoop 生态把 LZ4 块格式写成 `.lz4` 后缀 | +| `ConnectorScanRange.isNativeReadRange()` (`:166`) | 仅 paimon | 只为在 EXPLAIN 里打印一行 `paimonNativeReadSplits=x/y` | +| `ConnectorPartitionValues.HIVE_DEFAULT_PARTITION` (`:26`) | 公共常量 | Hive 的魔法字符串 `__HIVE_DEFAULT_PARTITION__` 写死在中立的 scan 包里 | +| `ConnectorContext.sanitizeJdbcUrl(url)` (`:79`) | 引擎侧服务 | 引擎给所有连接器提供的服务里出现"JDBC URL 清洗" | +| `ConnectorValidationContext.validateAndResolveDriverPath` / `computeDriverChecksum` (`:50` / `:59`) | 引擎侧服务 | "驱动包路径校验"和"驱动包 MD5"只对 JDBC 有意义 | +| `Connector.schemaCacheTtlSecondOverride()` (`:359`) | iceberg、paimon | 注释直接说明它的存在是为了让 Paimon 的缓存开关也能管 schema 缓存 | + +> **逐行状态(复核 `53a753e0a1b`)** +> +> | 表中的方法 | 复核结论 | +> |---|---| +> | `executeRestRequest` | **已落地**(`1f2452165ec`):搬到窄接口 `ConnectorRestPassthrough`,经 `Connector.getRestPassthrough()` 判空探针暴露——正是下面"解决方向 3"提的做法 | +> | `executeStmt` / `getColumnsFromQuery` | **仍然成立**。两者仍是 `ConnectorTableOps` 的 default 方法,唯一实现仍是 jdbc;按域拆分之后,这两个是聚合接口自身**仅剩的两个方法**,接口注释已自认它们"应当独立成可选接口" | +> | `isPartitionValuesSysTable` | **仍然成立**,符号搬到 `ConnectorTableMetadataOps`,引擎仍在直接调用 | +> | `getSerializedTable` | **符号已不存在**:`60e9fe6f8f4` 把扫描节点属性键契约集中之后,它变成属性 map 里的一个中立键——正是下面"解决方向 2"提的做法 | +> | `adjustFileCompressType` | 见下方**交叉核对修正**,本行应从这张表移除 | +> | `isNativeReadRange` | **仍然成立**,引擎仍在读它 | +> | `HIVE_DEFAULT_PARTITION` | **已中立化改名**为 `ConnectorPartitionValues.NULL_PARTITION_NAME`(`ec24a866d45`,值一个字节没动,它是持久化标识);**但没有下沉到 hive**,见下方交叉核对修正 | +> | `sanitizeJdbcUrl` | **仍然成立** | +> | `validateAndResolveDriverPath` / `computeDriverChecksum` | 见下方**交叉核对修正**:前者不是 JDBC 专有 | +> | `schemaCacheTtlSecondOverride` | **仍然成立** | + +还有两个偏"默认值不中立"的问题: + +- `ConnectorScanRange.getFileFormat()` 默认返回 `"jni"`(`:67`)。"jni"不是一种文件格式, + 而是 BE 侧的一种读取机制。把格式和机制混在一个字段里,会让新连接器误以为自己必须返回某种文件格式。 +- `ConnectorScanRange.getTableFormatType()` 默认返回 `"plugin_driven"`(`:121`),而注释举的例子是 `"jdbc"`、`"hive"`。 + 这个字符串直接决定 BE 用哪个读取器,属于 FE/BE 之间的约定,却没有任何类型约束。 + +**为什么会变成这样**:这些方法几乎都是迁移过程中"某个连接器需要一个口子"时加上去的。 +加一个 `default` 方法成本极低(不影响其他连接器),于是公共接口逐渐变成了所有连接器需求的并集。 + +**解决方向**(三条,按适用场景选): + +1. **能中立化的就中立化**。例如 `adjustFileCompressType` 的本质是"连接器有权决定最终压缩类型", + 把方法名和文档改成中立表述(不再提 Hadoop 和 LZ4)即可; + `HIVE_DEFAULT_PARTITION` 的本质是"连接器自己的空分区哨兵值",应该由 hive 连接器持有, + 公共层只保留"连接器可以声明哪些值代表 NULL"的中立能力(`ConnectorPartitionInfo` 已经有 `partitionValueNullFlags` 这条更好的路子)。 +2. **纯展示/诊断类的,改成中立的键值对**。`isNativeReadRange` 只服务于 EXPLAIN 的一行文字, + 而 `ConnectorScanPlanProvider.appendExplainInfo` 已经提供了"连接器自己往 EXPLAIN 里追加内容"的通道, + 前者应该被后者吸收掉。`getSerializedTable` 同理——它的内容最终就是进 `nodeProperties` 的一个值,没必要单独开一个方法。 +3. **真正只属于一种数据源的,移到专门的可选接口上**。参考 api 里已有的好做法: + `RewriteCapableTransaction`、`WriteBlockAllocatingConnectorTransaction` 是两个**窄的、可选实现的**接口, + 引擎用 `instanceof` 判断,连接器不实现就等于不支持。 + `executeRestRequest`(REST 透传)、`executeStmt` + `getColumnsFromQuery`(SQL 透传) + 完全可以照这个模式做成 `ConnectorRestPassthrough`、`ConnectorSqlPassthrough` 两个小接口。 + 同理,`ConnectorValidationContext` 里的驱动包相关方法应该拆到一个 JDBC 专用的校验上下文里。 + +> **交叉核对修正 ①(针对解决方向 1 的前半句):`adjustFileCompressType` 不算中立性违规,这一整条撤销。** +> 它的方法名与默认实现(恒等)本来就是中立的;javadoc 里提 Hadoop 与 LZ4 的那一段,**恰恰是在解释这个钩子为什么必须存在**,末句原文就是"把这个 hadoop 专有事实圈在连接器里,通用节点保持与数据源无关"。它是"通用节点不得出现数据源专有代码"这条规则的**正面示范**,不是违规。把这段说明删掉,只会让下一个维护者不知道这个钩子干什么,进而把 LZ4 重映射写回通用扫描节点。 +> 注意:它出现在 G3 那份 thrift 类型清单里的那一行**依然成立**(入参是 `TFileCompressType`),两者不矛盾——一处讲语义中立性,一处讲类型依赖。 +> +> **交叉核对修正 ②(针对解决方向 1 的后半句):空分区哨兵不能下沉到 hive 连接器。** +> 三条硬约束:引擎自己的分区路径解析在用它(`FilePartitionUtils`);引擎渲染 `partition_values()` 表函数那一格也在用它(`MetadataGenerator`);**非 hive 的 paimon 连接器主动把空分区归一成这个串**。下沉会让 `fe-core` 反过来依赖 hive 插件,直接违反"`fe-core` 只出不进"的纪律。 +> 实际走的是另一条路(`ec24a866d45`):**原地中立化改名** `HIVE_DEFAULT_PARTITION` → `NULL_PARTITION_NAME`,值一个字节没动,并把引擎侧那份重复定义删掉——调研日 FE 树里有两份同串定义,现在只剩一份。真正下沉的是**只对目录名式分区成立**的那三个归一方法,它们被内联进唯一使用者 hudi(`2ed7da4cb68`),hudi 自己持有私有的 `"\N"`。 +> +> **交叉核对修正 ③(针对上表第 10 行与解决方向 3 的末句):`validateAndResolveDriverPath` 不是 JDBC 专有,这条建议在调研日就是错的。** +> iceberg 与 paimon 都在用它做 `CREATE CATALOG` 的驱动 URL 安全门(paimon 侧还有单测钉住),照"拆到 JDBC 专用校验上下文"去做会**直接打断这两个连接器**。真正只属于 JDBC 的只有 `computeDriverChecksum`(唯一调用方是 jdbc)。 + +--- + +### C. 无人调用的死接口 + +这些方法/类型仍在公共接口里,但**在整个仓库(排除测试)中找不到引擎侧的调用点**。 +它们的危害不是运行时错误,而是误导:新连接器的作者读到接口时会认真去实现它们。 + +#### C1. 扫描分片类型(`ConnectorScanRangeType` + 两个 getter) + +> **状态(复核 `53a753e0a1b`):已整族删除**(`da60bfadbac`)。枚举、两个 getter、以及连带发给 BE 的那个死属性键全部消失,全仓零命中;新增的双向断言测试守住"不再有这个键发给 BE"。 +> +> **交叉核对修正**:本节写的"全部 7 个连接器"是 **8 个**——漏了 trino(`TrinoScanRange`)。按"7 个"去删会漏改 trino 而编译失败。另外,两个方法的默认值情况**并不对称**:分片上那个是接口抽象方法、没有默认实现,所以 8 个分片类被迫逐个实现;扫描计划提供者上那个**有默认值** `FILE_SCAN`,只有 hive / es / jdbc 三家做了与默认值等价的多余覆写。 + +- `ConnectorScanPlanProvider.getScanRangeType()`(`:52`),文档写着"引擎据此决定生成哪种 Thrift 扫描分片结构"。 +- `ConnectorScanRange.getRangeType()`(`:43`),同样的说法。 + +实际情况:`fe-core/src/main` 中**没有任何一处读取这两个方法**(只有测试类里的匿名实现)。 +引擎统一走文件扫描分片结构,具体差异由 `populateRangeParams` 由连接器自己填。 +但这两个方法**没有默认实现或者默认值形同虚设**,于是全部 7 个连接器都老老实实实现了 `getRangeType()` +(es / hive / hudi / iceberg / jdbc / maxcompute / paimon),hive、es、jdbc 还额外实现了 `getScanRangeType()`。 + +这是一次纯粹的无效劳动,而且文档是错的。 + +**建议**:删除 `ConnectorScanRangeType` 枚举和这两个方法;如果将来真的需要多种分片结构, +应该由 `ConnectorScanRange` 的具体子类型来表达,而不是一个平行的枚举标签。 + +#### C2. `ConnectorScanPlanProvider.estimateScanRangeCount`(`:432`) + +> **状态(复核 `53a753e0a1b`):仍然成立,且是 C 段唯一的幸存者。** 44 个提交没有一个碰过它——接口声明仍在 `ConnectorScanPlanProvider`,唯一实现仍是 `JdbcScanPlanProvider`,`fe-core` 仍然零调用。C 段其余六条(C1 / C3 / C4 / C5 / C7,以及 C6 的一半)都已处置,**只有这一条被漏下了**。 + +文档说"引擎可能用它预分配资源或决定扫描并行度"。全仓搜索结果:**只有接口声明和 JDBC 的一个实现,零调用点**。 + +**建议**:删除。真正在用的并行度提示是 `streamingSplitEstimate`(见后文),两者功能重叠。 + +#### C3. `ConnectorTableOps.listPartitionValues`(`:499`) + +> **状态(复核 `53a753e0a1b`):已删除**(`891709a08e7`)。该提交的正文同时记录了本节指出的文档错误——javadoc 指向 `partition_values()`,实际走的是分区列举那条路。 + +文档说"被 `partition_values()` 表函数和列去重优化使用"。 +实际情况:`partition_values()` 表函数(`fe-core/.../tablefunction/PartitionValuesTableValuedFunction.java`) +走的是分区列 + 分区项那条路,从不调用这个方法。 +但 paimon、maxcompute、hudi 三个连接器都实现了它(并且实现里还要处理列顺序对齐等细节)。 + +**建议**:确认历史上的调用点确实已经迁走后删除;若还有保留价值,必须把文档改成真实用途。 + +#### C4. 连接器属性描述(`ConnectorPropertyMetadata` + `Connector.getTableProperties()` / `getSessionProperties()`) + +> **状态(复核 `53a753e0a1b`):已删除**(`a7de939fba9`),选的是本节两个选项里的"删除"。值对象与 `Connector` 上那两个 getter 一并消失。**注意不要误伤同名方法**:`PluginDrivenExternalTable.getTableProperties()` 与 `ConnectorSessionImpl.getSessionProperties()` 是引擎侧另外两个活方法,与本节无关。 + +`ConnectorPropertyMetadata` 是一个 120 行、带泛型和 4 个工厂方法的完整值对象, +配套 `Connector` 上两个返回 `List>` 的方法(`Connector.java:235` 和 `:240`)。 + +全仓搜索(含测试目录):**除了它自己的定义文件之外,`ConnectorPropertyMetadata` 这个类型名在整个仓库里一次都没出现过**。 +(注意不要与 `PluginDrivenExternalTable.getTableProperties()` 混淆,那是另一个同名方法,返回的是普通字符串 map。) + +这是一个完整设计好、但从未接线的属性描述子系统。 + +**建议**:要么删除,要么补上真实用途(例如驱动 `CREATE CATALOG` 的属性校验和 `SHOW` 类语句的属性文档)。 +保持现状是最差的选择——它给新连接器作者一个错误信号,以为需要声明属性元数据。 + +#### C5. `ConnectorPartitionHandle`(handle 包) + +> **状态(复核 `53a753e0a1b`):已删除**(`d258eb20f2a`)。 + +一个空的标记接口,全仓零引用(fe-core 和所有连接器都没用过)。 + +**建议**:删除。 + +#### C6. 与"是否重写了方法"重复的能力声明 + +> **状态(复核 `53a753e0a1b`):仍然成立。** 布尔位与建库方法都还在,仍然靠实现者手工保持同步;本节提的两个补救(改抛"不支持"异常 / 加契约测试)都没做。 + +`ConnectorSchemaOps.supportsCreateDatabase()`(`:58`)与 `createDatabase(...)`(`:63`)是一对: +前者返回 true 才会走"建库前先检查远端是否存在"的逻辑,后者是真正的建库实现。 +当前 4 个连接器(hive / iceberg / paimon / maxcompute)两者都实现了,暂时没有不一致。 + +问题在于这两者**在语义上必然同进同退**,却由实现者手工保持同步。 +一个新连接器只实现 `createDatabase` 而忘了 `supportsCreateDatabase`, +`CREATE DATABASE IF NOT EXISTS` 的行为就会悄悄退化,且没有任何检查会发现。 + +**建议**:删除布尔方法,改为让默认的 `createDatabase` 抛出一个引擎可识别的"不支持"异常, +引擎据此判断;或者至少加一条契约测试强制两者一致。 + +#### C7. `Connector` 上 6 个写特性的空安全转发 + +> **状态(复核 `53a753e0a1b`):已删除**(`dfbefc790e8`),而且删得比本节建议的更彻底——不是"把空安全逻辑挪到引擎侧工具方法",而是把这一族**连同 per-handle 变体共 11 个**(本节估的是 9 个)整体删掉,引擎改为先按表句柄取写计划提供者、再直接问它。副作用是 G1 描述的对称性缺口**在结构上不再可能存在**。 + +`Connector.java:132`–`:186` 有 6 个方法(`supportsWriteBranch`、`requiresParallelWrite`、 +`requiresFullSchemaWriteOrder`、`requiresPartitionLocalSort`、`requiresPartitionHashWrite`、 +`requiresMaterializeStaticPartitionValues`),每一个的实现都是同一句话: +"取写计划提供者,为 null 则返回 false,否则转发同名方法"。 + +核对结果:**没有任何连接器重写过 `Connector` 上的这 6 个方法**,全部只在 `ConnectorWritePlanProvider` 上实现。 +所以这 6 个方法纯粹是给引擎用的便利转发。 + +**建议**:这不算错误,但它让 `Connector` 接口膨胀了 6 个方法(加上 3 个 per-handle 变体共 9 个), +新连接器作者需要判断"这些我要不要实现"。 +更干净的做法是把这段空安全逻辑放到引擎侧的一个工具方法里,`Connector` 只保留 `getWritePlanProvider(handle)`。 + +#### C8. 重载堆叠 + +> **状态(复核 `53a753e0a1b`):部分处置。** 建表的两个重载已合成**一个**(javadoc 明写"刻意只保留一个 create"),删掉的正是本节点名的那个丢信息的 legacy 版;建库的 cascade 重载也已收成单个四参版本,javadoc 记下了旧重载导致 `DROP DATABASE ... FORCE` 静默不级联的原因。**`planScan` 的 4 个重载、以及"带快照 / 不带快照"那两组重载原样还在**,本节提的"一个方法 + 一个请求对象"没有做。 + +- `ConnectorScanPlanProvider.planScan` 有 **4 个重载**(4/5/6/7 参数),后一个默认委托给前一个。 + 新连接器必须读完 4 段文档才知道该实现哪一个。 +- `ConnectorTableOps.createTable` 有 2 个重载,旧的那个(`schema, properties`)会丢掉分区、分桶、`IF NOT EXISTS` 信息, + 文档自己称之为"legacy"。而且它的两个参数存在冗余——`ConnectorTableSchema` 里本来就有 `properties` 字段, + 默认实现(`:241`)把 `request.getProperties()` 同时塞进 schema 和第二个参数。 +- `ConnectorSchemaOps.dropDatabase` 有 2 个重载(是否 cascade)。 +- `ConnectorTableOps.getTableSchema` / `getColumnHandles`、`ConnectorStatisticsOps.getTableStatistics` + 各有"带快照"和"不带快照"两个版本。 + +**建议**:把参数逐步增长的重载合并成**一个方法 + 一个请求对象**。 +`ConnectorCreateTableRequest` 已经是这个模式的正确示范,`planScan` 应该照做 +(`ConnectorScanRequest` 承载 columns / filter / limit / requiredPartitions / countPushdown), +这样以后再加规划维度就不用再开一个重载。带快照的那组重载可以把快照并入请求对象或做成可选参数。 + +--- + +### D. 能力声明有 5 套并存机制 + +这是新连接器作者最容易困惑的地方。目前"我支持某个功能"可以用 5 种完全不同的方式表达: + +| 机制 | 例子 | 引擎侧判断方式 | +|---|---|---| +| 1. 枚举集合 | `getCapabilities()` 返回 `SUPPORTS_VIEW` | 集合包含判断 | +| 2. 接口上的布尔方法 | `supportsCreateDatabase()`、`supportsTableSample()`、`supportsBatchScan()`、`supportsColumnHandleSnapshotPin()`、`supportsCastPredicatePushdown()`、`supportsSystemTableTimeTravel()` | 直接调用 | +| 3. getter 返回 null 表示不支持 | `getScanPlanProvider()`、`getWritePlanProvider()`、`getProcedureOps()`、`getEventSource()` | 判空 | +| 4. 窄的可选接口 + `instanceof` | `RewriteCapableTransaction`、`WriteBlockAllocatingConnectorTransaction` | 类型判断 | +| 5. 表级能力用字符串 CSV 传递 | `ConnectorTableSchema` 的 `__internal.connector.per-table-capabilities` 键,值是枚举名的逗号串 | 解析字符串 | + +五套机制的取舍确实各有道理(枚举适合静态、getter 适合有实现体、`instanceof` 适合"不支持就没有这个方法"), +但目前**没有一处文档说明选择规则**,结果是同一类问题在不同地方用了不同解法。 +最典型的矛盾是:写能力(`WriteOperation` 集合)曾经在枚举里、后来搬到了 provider 上, +`ConnectorCapability` 的类注释还专门解释了这件事; +而扫描能力(`SUPPORTS_TOPN_LAZY_MATERIALIZE`、`SUPPORTS_NESTED_COLUMN_PRUNE`)却留在枚举里。 + +第 5 种尤其值得注意:把一组枚举值序列化成 CSV 字符串,塞进本来用于承载表属性的 map, +再由 fe-core 解析回枚举。它绕开了类型系统,出错时不会在编译期暴露,只会在运行时表现为"能力莫名其妙没生效"。 + +**建议**:定一条明确的规则并写进 `ConnectorCapability` 的类注释,例如: + +- 有实现体的能力(读、写、存储过程、事件源)→ 用 getter 返回 null 表达; +- 纯粹的布尔开关且引擎需要在**规划期静态判断**的 → 进 `ConnectorCapability` 枚举; +- 只有一种数据源需要的窄能力 → 独立的可选接口 + `instanceof`; +- 需要**按表**变化的能力 → 这是目前唯一确实缺失的机制,应该做成一个正经的接口方法 + (例如 `Set getTableCapabilities(session, handle)`),而不是 CSV 字符串。 + +> **状态(复核 `53a753e0a1b`)** +> +> - **第 5 种机制已经消失**(`5a6d76abadb`):那个 `__internal.` 的 CSV 键全仓零命中,`ConnectorTableSchema` 上换成了类型化的 `Set` 字段与 getter,字段 javadoc 里还留了一句"它曾经是一个装枚举名 CSV 的保留键"。**本节建议的第 4 条已经落地**,形态与建议一致(载体是表 schema 而不是新的 `getTableCapabilities(session, handle)` 方法)——**现在再照建议加一个方法,会造出第二条并行通道**。 +> - **"没有一处文档说明选择规则"已被推翻**(`d572751af49`):`fe-connector-api` 的 `package-info.java` 里现在有"规则一 —— 一项能力只在三层里的**恰好一层**声明",把本节这四条取舍写成了成文规则。 +> - 前 4 种机制原样保留,本节对它们的描述仍然准确。 + +--- + +### E. 核心接口职责耦合 + +#### E1. `ConnectorTableOps` —— 43 个方法,7 类互不相干的职责 + +> **状态(复核 `53a753e0a1b`):已按域拆分**(`a9567456c44`),并且每个域接口都写了"最少实现集"。实际拆出来的 6 个域接口与本节提议的分组基本一一对应,只有三处命名不同:句柄/schema 组叫 `ConnectorTableMetadataOps`(不是继续叫 `ConnectorTableOps`)、列 DDL 组叫 `ConnectorColumnEvolutionOps`(不是 `ConnectorColumnDdlOps`)、分区枚举组叫 `ConnectorPartitionListingOps`(不是 `ConnectorPartitionOps`);系统表那三个方法没有单独成组,并进了 `ConnectorTableMetadataOps`。`ConnectorTableOps` 自身现在只剩 2 个方法(`executeStmt` / `getColumnsFromQuery`,见 B 段),其余全在域接口里。 +> +> **连带后果**:本文 B 段与 C 段所有形如 `ConnectorTableOps.java:NNN` 的锚点,现在都指向一个已经不含那个成员的文件——核对时请按符号名去对应的域接口里找。 + +按语义分组: + +| 职责 | 方法 | +|---|---| +| 表句柄与 schema | `getTableHandle`、`getTableSchema`×2、`getColumnHandles`×2、`supportsColumnHandleSnapshotPin`、`listTableNames` | +| 系统表 | `listSupportedSysTables`、`getSysTableHandle`、`isPartitionValuesSysTable` | +| 视图 | `viewExists`、`listViewNames`、`getViewDefinition`、`dropView` | +| 表级 DDL | `createTable`×2、`dropTable`、`renameTable`、`truncateTable`、`renderShowCreateTableDdl` | +| 列 DDL | `addColumn`、`addColumns`、`dropColumn`、`renameColumn`、`modifyColumn`、`reorderColumns` + 5 个嵌套列版本 | +| 快照引用与分区规格 DDL | `createOrReplaceBranch`、`createOrReplaceTag`、`dropBranch`、`dropTag`、`addPartitionField`、`dropPartitionField`、`replacePartitionField` | +| 分区枚举 | `listPartitionNames`、`listPartitions`、`listPartitionValues` | +| 杂项 | `getPrimaryKeys`、`getTableComment`、`executeStmt`、`getColumnsFromQuery`、`buildTableDescriptor` | + +一个只读的连接器(比如一个新的对象存储格式)需要实现的只有第一组和"分区枚举", +但它必须面对全部 43 个方法的文档才能确认这一点。 + +**建议**:按上表拆成 `ConnectorTableOps`(句柄/schema/列表)、`ConnectorViewOps`、`ConnectorTableDdlOps`、 +`ConnectorColumnDdlOps`、`ConnectorSnapshotRefOps`、`ConnectorPartitionOps` 若干个接口, +`ConnectorMetadata` 继续继承它们(对现有实现零影响,因为 Java 的接口继承是扁平的), +但新连接器可以按需只看自己关心的那几个。这是纯粹的文档与认知成本优化,不改变任何运行时行为。 + +#### E2. `ConnectorScanPlanProvider` —— 24 个方法,混了 6 类东西 + +> **状态(复核 `53a753e0a1b`):仍然完全成立。** 尤其是本节点出的那个矛盾——接口声称提供者"每次调用新建、无状态",却要求它释放另一次调用中开启的读事务——原样存在,hive 仍然靠连接器级的读事务管理器绕过。这是全文仍然开放的问题里最实的一条。 + +规划(`planScan` 4 个重载 + `streamSplits` + `planScanForPartitionBatch`)、 +能力开关(`supportsBatchScan` / `supportsTableSample` / `supportsSystemTableTimeTravel` / `ignorePartitionPruneShortCircuit`)、 +Thrift 填充(`populateScanLevelParams` / `getDeleteFiles`)、 +EXPLAIN 渲染(`appendExplainInfo`)、 +诊断(`collectScanProfiles`)、 +**事务生命周期**(`releaseReadTransaction`)。 + +最后一项特别值得指出:接口自己的文档在 `getScanPlanProvider(handle)` 处写明 +"提供者是每次调用新建的、无状态的"(`Connector.java:76`), +但 `releaseReadTransaction(queryId)`(`:539`)要求这个"无状态"对象能够释放另一次调用中开启的事务。 +hive 的实现只能靠把状态放在连接器级的 `HiveReadTransactionManager` 上来绕过这个矛盾 +(`HiveScanPlanProvider.java:347`)。也就是说,接口声明的对象生命周期和它承担的职责是冲突的。 + +**建议**:把每查询的读事务生命周期移到 `Connector` 上(连接器才是长生命周期对象), +或者引入一个显式的"查询级资源"抽象;EXPLAIN 与诊断方法可以合并成一个"扫描诊断"接口。 + +#### E3. `Connector` —— 32 个方法 + +> **状态(复核 `53a753e0a1b`):大部分已落地,方法数 34 → 21。** 本节列举的负担里,**REST 透传**、**属性描述**、**写特性转发(实为 11 个)** 三项已经删除或搬走;**缓存失效那 4 个 `invalidate*` 原样还在**,本节建议的"拆成 `ConnectorCacheInvalidation` 可选接口"没有做。异构网关路由(`ownsHandle` + per-handle getter)也还在,但 per-handle getter 从 4 组减到 3 组。 + +除了合理的入口方法外,还承担了:缓存失效(4 个 `invalidate*`)、连通性测试(3 个)、 +存储属性推导、schema 缓存 TTL 覆盖、REST 透传、属性描述(死接口)、异构网关路由(`ownsHandle` + 4 组 per-handle getter)、 +写特性转发(9 个)。 + +**建议**:至少把缓存失效(4 个方法)拆成一个 `ConnectorCacheInvalidation` 可选接口—— +大多数连接器不缓存任何东西,这 4 个方法对它们完全是噪音。 + +#### E4. `ConnectorContext` —— 19 个方法,引擎服务的大杂烩 + +包含:身份(catalog 名/id)、认证(`executeAuthenticated`)、HTTP 安全钩子、JDBC URL 清洗、 +缓存失效回调、兄弟连接器工厂、**存储相关的 8 个方法**(凭证归一化、URI 归一化 3 个重载、 +BE 文件类型、broker 地址、BE 存储属性、类型化存储属性、文件系统、空目录清理)、BE 连通性探测。 + +存储那 8 个方法明显自成一体,应该是一个独立的 `ConnectorStorageContext`(由 `ConnectorContext` 提供)。 + +> **状态(复核 `53a753e0a1b`):仍然成立,一步未动。** `ConnectorStorageContext` 这个类型名全仓零命中,从未被创建过。这一条已经在另一条工作线里单独立项,被标为**高危**——它跨引擎与全部插件的边界,落地必须配插件包重部署冒烟。 + +#### E5. `ConnectorMetadata` 是一个约 70 方法的聚合接口 + +> **状态(复核 `53a753e0a1b`):结构未变,自身方法 11 → 10**("属性"那个已随 F4 一并删除)。本节指出的"MVCC 与 handle 改写方法散在 `ConnectorMetadata` 自身而不是一个 Ops 子接口里"仍然成立。注意它继承的 6 个接口是 `ConnectorSchemaOps` / `ConnectorTableOps` / `ConnectorPushdownOps` / `ConnectorStatisticsOps` / `ConnectorWriteOps` / `ConnectorIdentifierOps`——而 `ConnectorTableOps` 自己又继承了 6 个域接口,所以家族合计已是 75 个声明。 + +它继承 6 个 Ops 接口,自己再加 11 个(属性、5 个 MVCC 相关、3 个 handle 改写、快照 schema)。 +这些 MVCC 与 handle 改写方法(`applySnapshot`、`applyRewriteFileScope`、`applyTopnLazyMaterialization`) +在语义上属于同一族——"在规划前把某个信息织进表句柄"——但它们分散在 `ConnectorMetadata` 自身而不是一个 Ops 子接口里。 + +--- + +### F. 语义不清的接口 + +#### F1. `ConnectorScanRange.getLength()` 的单位不确定(`:56`) + +> **状态(复核 `53a753e0a1b`):仍然成立,一步未动。** `getLength()` 的文档仍然只写"字节数",各连接器的语义分歧原样存在,`supportsTableSample()` 这个补丁式的布尔开关也还在,仍然只有 hive 声明。 + +文档写"要读取的字节数,-1 表示整个文件"。 +实际语义按连接器而异:hive/iceberg 是字节数;MaxCompute 默认与 Paimon 的 JNI 分片返回 -1; +MaxCompute 的行偏移模式返回的是**行数**。 + +这个歧义已经产生过真实后果:任何"按大小做采样/切分"的通用逻辑都不能直接用这个值, +所以 `supportsTableSample()`(`ConnectorScanPlanProvider:268`)才不得不存在, +用一个额外的布尔开关来声明"我的 length 真的是字节数"。目前只有 hive 声明了它。 + +**建议**:把字段拆开——`getLengthInBytes()` 语义唯一,行数之类的连接器私有信息进 `properties`。 +在此之前,至少要把这个歧义写进 `getLength()` 的文档(现在文档是明确说"字节"的,与事实不符)。 + +#### F2. `null` 与空集合的三态区分 + +三处接口用"null 和空集合表示不同含义"来编码信息: + +- `ConnectorWritePlanProvider.getWriteSortColumns`(`:96`):`null` = 无写排序;空 list = 有排序但列不可解析。 +- `ConnectorWritePlanProvider.getWritePartitioning`(`:119`):`null` = 未分区;空 spec = 另一回事。 +- `ScanNodePropertiesResult`:靠一个 `hasConjunctTracking` 布尔区分"没有跟踪"和"跟踪了但全部下推"。 + +> **交叉核对修正:第二条不成立,应从本节删除。** `getWritePartitioning` 只有**两态**,不存在"空 spec = 另一回事"这个第三态。接口 javadoc 写得很明确,还给了理由:`null`(**而不是空 spec**)表示目标未分区,对齐旧实现的"是否已分区"判据,引擎据此回退到非分区的合并分布。唯一的生产者是 iceberg(未分区表返回 `null`,有测试钉住),唯一的消费者是引擎侧的行级合并写入节点,全仓没有任何一处让"空规格"表示第三种含义。 +> 同一节的另外两条(写排序列的 `null` vs 空 list、`hasConjunctTracking` 布尔位)**是真的**,且复核日仍然成立——写排序列的接口默认值确实是 `null`。 +> +> **状态(复核 `53a753e0a1b`)**:本节提的补救(改用 `Optional` 或显式的 `WriteOrdering.none()`)没有做。 + +这种编码方式很容易被实现者写反(返回 `Collections.emptyList()` 而不是 `null` 就会改变行为), +而且编译器不会提醒。 + +**建议**:改用 `Optional>`,或者引入显式的 `WriteOrdering.none() / WriteOrdering.of(...)`。 + +#### F3. `ConnectorWriteHandle.getWriteContext()` 名不符实 + +> **状态(复核 `53a753e0a1b`):仍然成立。** 连接器侧的方法名没改;而引擎侧现在已经在用本节提议的那个名字(`getStaticPartitionSpec()`),于是两个名字跨边界并存,比调研日更值得统一。 + +方法名是"写上下文"(暗示是一个自由的信息袋),但它的文档自己承认: +唯一的生产者只往里放静态分区规格,三个消费方(hive/iceberg/maxcompute)也都当静态分区规格用。 + +**建议**:直接改名为 `getStaticPartitionSpec()`。 + +#### F4. `ConnectorMetadata.getProperties()` 没有契约(`:54`) + +> **状态(复核 `53a753e0a1b`):本节已作废——符号已删除**(`891709a08e7`),选的是本节两个选项里的"删除"。 + +文档只有一句"返回连接器级属性"。是 catalog 属性?是连接器自己派生的属性?给谁看的? +在 fe-core 的插件驱动路径上找不到调用点。 + +**建议**:补充契约或删除。 + +#### F5. `ConnectorSession` 有三条读配置的路径且互相重叠 + +> **状态(复核 `53a753e0a1b`):仍然成立,一步未动。** 两个命名空间仍在 `getProperty` 里被静默合并,同名时会话变量仍然静默覆盖 catalog 配置。 + +`getCatalogProperties()`(catalog 属性全集)、`getSessionProperties()`(会话变量全集)、 +`getProperty(name, type)`(`ConnectorSession.java:75`)。 +第三个的引擎实现(`ConnectorSessionImpl.java:131`)会**先查会话变量、再查 catalog 属性**—— +两个命名空间被静默合并,调用方无法知道拿到的值来自哪一边,同名时会话变量会静默覆盖 catalog 配置。 + +**建议**:让 `getProperty` 明确它查哪个命名空间,或者干脆删掉它(只有 hive 的 2 处在用)。 + +#### F6. 参数风格不统一:句柄 vs 字符串名 + +`ConnectorTableOps` 内部同时存在两种寻址方式: + +- 用表句柄:`getTableSchema(session, handle)`、`dropTable(session, handle)`、`listPartitions(session, handle, filter)`…… +- 用库名/表名字符串:`getPrimaryKeys(session, dbName, tableName)`、`getTableComment(session, dbName, tableName)`、 + `viewExists(session, dbName, viewName)`、`getViewDefinition(...)`、`dropView(...)` + +后者绕过了句柄,意味着连接器要么重新解析一次表、要么维护两条查找路径。 +更实际的影响是:**异构网关连接器无法按句柄把请求路由给正确的兄弟连接器**, +因为字符串参数里没有任何信息说明这张表属于哪种格式。 + +> **交叉核对修正:结论的后半句不成立。** 前半句是对的——网关**确实无法用"按句柄类型路由"这个惯用手法**去处理名字寻址的方法,这一点现在已经明文写进 SPI 文档(`ConnectorTableMetadataOps` 的接口注释专门有一段:"某方法按**名字**而不是句柄寻址;异构网关按具体句柄类型把外来表路由给兄弟连接器,因此无法这样路由名字寻址的方法;如果你在写网关,必须显式处理它")。 +> 但后半句"字符串参数里没有任何信息说明这张表属于哪种格式"是**错的**:网关完全可以拿库名/表名回查一次元数据再判定格式,hive 网关现在就是这么做的(取回 HMS 表对象 → 交给格式探测器 → 只对 iceberg 表返回注释,取不到就降级成空串)。这条路径正是靠它修好了"异构目录下 iceberg 表注释显示为空"这个用户可见缺陷。 +> +> **状态(复核 `53a753e0a1b`)**:本节建议的"统一为句柄寻址"没有做;名字寻址的那一组里,`getPrimaryKeys` 已随死接口面一并删除(`891709a08e7`),其余仍在。 + +**建议**:统一为句柄寻址。视图相关的方法确实可能在拿到句柄前调用,那就应该有一个明确的 +"库/表名寻址"分组并说明为什么,而不是与句柄方法混排。 + +#### F7. 两个方向相反但同名的失效接口 + +- `Connector.invalidateTable/invalidateDb/invalidateAll/invalidatePartition`(`Connector.java:312`–`:336`): + **引擎 → 连接器**,用于 `REFRESH TABLE` 等命令通知连接器丢弃自己的缓存。 +- `ConnectorMetaInvalidator.invalidateTable/invalidateDatabase/invalidateAll/invalidatePartition/invalidateStatistics` + (`fe-connector-spi/.../ConnectorMetaInvalidator.java`): + **连接器 → 引擎**,用于连接器收到元数据变更事件后通知引擎丢弃缓存。 + +两组方法名几乎完全一样、参数也几乎一样(只有 `invalidateDb` vs `invalidateDatabase`、 +以及 partition 参数一个是"分区名列表"一个是"分区值列表"这两处细微差别),方向却相反。 +这是一个非常容易写错的设计,而且写错了不会报错,只会表现为"缓存偶尔不刷新"。 + +> **状态(复核 `53a753e0a1b`):命名冲突已经消失,本节的改名建议应当撤销。** +> "连接器 → 引擎"那一整套推模型(`ConnectorMetaInvalidator` 及其引擎侧实现)已由 `0a21334f95e` 删除——它是死的:连接器 `src/main` 里除了两个类加载器钉桩包装类的透明转发没有任何生产调用,而引擎侧实现自己在注释里承认按分区失效履约不了(降级成整表失效)、统计失效是空操作。反向的元数据变更通知现在走**拉模型**(引擎侧的事件同步驱动定期向连接器 `pollOnce`)。 +> 活着的那一组(`Connector.invalidate*`,引擎 → 连接器)原样保留。**不要**为了消歧义去给它改名而动 8 个连接器——名字已经不撞了。 + +**建议**:至少把其中一组重命名以体现方向(例如引擎→连接器的那组叫 `onRefreshTable/onRefreshDatabase`), +并统一 partition 参数的语义(现在一个传规范化分区名、一个传分区值列表)。 + +#### F8. 用字符串 map 传递结构化信息 + +> **状态(复核 `53a753e0a1b`):部分落地。** 七个保留键里,**表级能力那个已经提升为类型化字段**(`5a6d76abadb`,见 D 段状态戳),**主键列那个已随死接口面删除**(`891709a08e7`)。分区列、分桶列、以及三个"已渲染好的 SQL 子句"仍然靠字符串键传递,本节对它们的批评仍然成立。 + +`ConnectorTableSchema` 定义了 7 个 `__internal.` 前缀的保留键,用来在**表属性 map** 里夹带结构化信息: +分区列(CSV)、主键列(CSV)、分桶列(CSV)、表级能力(枚举名 CSV)、 +以及三个已渲染好的 SQL 片段(LOCATION 子句、PARTITION BY 子句、ORDER BY 子句)。 + +这么做的原因可以理解——避免为每种信息都往 `ConnectorTableSchema` 加字段。 +但代价是:这些本该是类型化字段的东西,现在靠字符串键约定传递,拼错一个键不会有任何提示。 +尤其是"已渲染的 SQL 子句"这三个键,等于让连接器直接生成 Doris SQL 文本, +把语法渲染的责任推给了插件;一旦 Doris 的 DDL 语法变化,所有连接器都要跟着改。 + +**建议**:把分区列/主键列/分桶列/表级能力提升为 `ConnectorTableSchema` 的正式字段(有类型、有默认值); +三个 SHOW CREATE 相关的渲染键,改为让连接器返回结构化描述(例如分区字段 + 变换名 + 参数),由 fe-core 负责渲染文本。 + +--- + +### G. 对称性缺口与潜在缺陷 + +#### G1. 6 个写特性只有 3 个提供了"按表"变体 + +> **状态(复核 `53a753e0a1b`):已落地,走的是本节推荐的那条路。** `dfbefc790e8` 把 `Connector` 上这一族转发方法(连同 per-handle 变体共 11 个)整体删除,引擎改为先 `getWritePlanProvider(handle)` 拿到提供者、再直接读特性。**"按表语义天然成立"因此兑现了,这个对称性缺口在结构上不再可能出现。** + +异构网关连接器(hive catalog 同时服务 plain-hive、iceberg-on-HMS、hudi-on-HMS 三种表) +需要按表决定用哪个写计划提供者。`Connector` 为此提供了带表句柄的重载,但**只覆盖了一半**: + +| 写特性 | 有连接器级方法 | 有按表变体 | +|---|---|---| +| `supportedWriteOperations` | 是 | 是 | +| `supportsWriteBranch` | 是 | 是 | +| `requiresPartitionHashWrite` | 是 | 是 | +| `requiresMaterializeStaticPartitionValues` | 是 | 是 | +| `requiresParallelWrite` | 是 | **否** | +| `requiresFullSchemaWriteOrder` | 是 | **否** | +| `requiresPartitionLocalSort` | 是 | **否** | + +核对当前实现:hive 与 iceberg 的 `requiresParallelWrite` 和 `requiresFullSchemaWriteOrder` **恰好都是 true**, +两者的 `requiresPartitionLocalSort` **恰好都是 false**(只有 MaxCompute 是 true), +所以今天不会出错。但这属于"碰巧对齐",不是设计保证。 + +一旦出现某个通过网关委派、且这三项与网关本身不同的表格式(例如把 MaxCompute 作为兄弟连接器接入), +引擎就会拿网关自己的写特性去规划兄弟连接器的写入,表现为分布方式错误 → 输出文件数异常或写入结果不正确。 + +**建议**:要么补齐三个缺失的按表变体,要么(更好)取消这 9 个转发方法, +让引擎直接通过 `getWritePlanProvider(handle)` 拿到 provider 再读特性——那样按表语义天然成立,不存在对称性问题。 + +#### G2. 写能力自洽性校验从未在真实连接器上运行 + +> **交叉核对修正:本节的核心事实在调研日就是错的。** 用 `git grep` 回到 `7ff51a106f0` 复核,那一刻**已经有四个真实连接器的契约测试在调用** `validate`:iceberg、elasticsearch、jdbc、maxcompute。所以"8 个真实连接器没有任何一个调用过它""这 4 条规则今天完全没有被验证"两句都不成立,类注释描述的机制是**部分已经实现**的机制、不是虚构的机制。 +> 真实缺口比原文窄得多、也具体得多:**唯一声明"分区哈希写"的 hive 没有调用点**,因此涉及它的两条不变量缺少真实连接器的正样本,只有引擎侧那个假连接器测试在压。照原文"给 8 个连接器各补一个契约测试"去做,其中 4 个是重复建设,而真正缺的那一个原文根本没点出来、最可能被漏掉。 +> +> **状态(复核 `53a753e0a1b`)**: +> - 类注释已经修好(`29b46660ac4`),现在专门有一段"Actual coverage today",写明四家在调、并点名 hive 是那两条不变量唯一的空白。**H2 那一小节因此整条作废。** +> - 调用点数量没变(4 个连接器 + 1 个引擎侧假连接器测试),但四家的**有效性**并不一样:es 是只读连接器,写计划提供者为 `null`,`validate` 一进来就早退,等于零条不变量被压到;jdbc 有写但四个前件全 `false`,属于空过;**真正压到真前件的只有 iceberg(声明分支写)与 maxcompute(声明分区本地排序)**。 +> - 本节提的"把检查扩展到按表变体"没有做,而且现在有了更明确的说法:验证器读的是连接器级提供者,引擎写路径按表解析提供者,异构网关可以在这里自洽却按表答得不同——这一点已被写进类注释,标为**有意为之、不在验证器职责范围内**。 + +`ConnectorContractValidator`(`fe-connector-api/.../ConnectorContractValidator.java`) +定义了 4 条写能力自洽规则(例如"要求分区本地排序就必须同时要求并行写和全 schema 顺序"、 +"两个分区分布模式互斥")。它的类注释写着: + +> 这些不变量由**各连接器的契约测试**(构建每个连接器并调用 `validate`)来强制执行。 + +实际情况:全仓唯一的调用点是 `fe-core/src/test/.../ConnectorContractValidatorTest.java`, +它用的是**手写的假连接器**,8 个真实连接器没有任何一个调用过它。 + +也就是说这 4 条规则今天完全没有被验证。 +更关键的是:它只检查连接器级的方法,即使被调用,也检查不到 G1 描述的按表场景。 + +**建议**:在每个连接器模块加一个契约测试真正调用它(这正是注释里承诺的做法), +并把检查扩展到按表变体;同时修正注释,不要描述并不存在的机制。 + +#### G3. Thrift 中立性存在两套相反的规则 + +`fe-connector-spi` 严格避开 Thrift:`ConnectorContext.getBackendFileType` 返回的是**枚举名字符串** +(`"FILE_S3"`),broker 地址用中立的 `ConnectorBrokerAddress` 而不是 `TNetworkAddress`, +并且两处都写了注释解释"为了让这个 SPI 保持无 Thrift 依赖"。 + +但 `fe-connector-api` 完全相反——它直接依赖 `fe-thrift`(`pom.xml` 里是 `provided` 依赖), +并在 5 个地方把 Thrift 类型放进接口签名: + +| 位置 | Thrift 类型 | +|---|---| +| `ConnectorScanRange.populateRangeParams` | `TTableFormatFileDesc`、`TFileRangeDesc` | +| `ConnectorScanPlanProvider.populateScanLevelParams` | `TFileScanRangeParams` | +| `ConnectorScanPlanProvider.adjustFileCompressType` | `TFileCompressType` | +| `ConnectorScanPlanProvider.getDeleteFiles` | `TTableFormatFileDesc` | +| `ConnectorWriteHandle.getSortInfo` / `ConnectorSinkPlan` | `TSortInfo` / `TDataSink` | +| `ConnectorTableOps.buildTableDescriptor` | `TTableDescriptor`(用全限定名内联写在签名里) | + +同一套契约的两半采用了完全相反的原则,而且没有任何文档说明为什么。 +这会让新连接器的作者无所适从:我到底能不能用 Thrift 类型? + +> **状态(复核 `53a753e0a1b`):规则已经写下来了,结论也正如本节预判,但两半并未统一。** `d572751af49` 在两个模块各写了一份包级说明,`fe-connector-api` 那份的"规则三"就是"thrift 类型只出现在 BE 协议边界上",并给了本节猜的那个理由(BE 是 C++、没有插件机制,发往 BE 的载荷只能是 thrift 类型),还列出了**完整的出现位置清单**并加了一条硬约束:"不要新增以 thrift 类型作**入参**的方法"。"没有任何文档说明为什么"这句已被推翻。 +> 但它没有把两半统一成一条规则:`fe-connector-spi` 的 thrift 回避被明确记为**那个模块的局部约定、而不是模块级不变量**,并写明"是否统一两种风格不在本次范围内"。也就是说本节指出的分歧被**如实记录**了,没有被消除。 +> 另外,`buildTableDescriptor` 用全限定名内联写在签名里这一点,已经被逐字写进那份权威清单("写成内联全限定名,不是 import")。它不再是一处"没人知道的风格不一致";要不要改成 import 现在纯属风格取舍。 + +**建议**:明确一条规则并写进两个模块的 `package-info`。 +现实地看,让连接器直接构造 `TDataSink` 是有道理的(否则引擎要理解每种 sink 的方言), +所以更可能的结论是"api 允许 Thrift,spi 不允许",那就应该把这条规则写清楚, +并顺手修掉 `buildTableDescriptor` 里用全限定名内联的写法(与其他 5 处风格不一致)。 + +--- + +### H. 实现与接口文档相互矛盾的具体案例 + +以下 4 处是逐条核对得出的、文档与实现直接冲突的地方。 + +#### H1. `listFileSizes` 的异常契约被实现有意违背 + +> **状态(复核 `53a753e0a1b`):仍然成立,而且证据比调研日更强——现在是三方矛盾。** 除了本节列出的接口文档与 hive 实现互相打脸之外,`d572751af49` 新写进包级说明的一条规则站在**实现**这一边:「响亮失败 vs 静默降级——用户显式要求的操作(DDL、写入、显式的采样式 `ANALYZE`)必须响亮失败,因为在那里吞掉错误会产生一个看起来很权威的错误结果;引擎为优化而机会性发起的操作才必须静默降级」,而且它**点名**了这条命令。 +> 三方里有两方说"该抛",所以本节的判断(该改的是接口文档)现在有了成文依据。**这一条至今没有人修**——负责"修正与实现矛盾的接口文档"的那一批工作把它明确排除在范围之外了(它被归到异常契约那一批),于是两批之间漏了下来。它是全文剩下的开放项里**最便宜也最该先做的一条**:改接口 javadoc 一句话,不动任何行为。 + +接口文档(`ConnectorStatisticsOps.java:87`–`:95`): + +> 尽力而为:重写方必须在任何列举错误时返回空集合而不是抛异常(统计信息不能让查询失败)。 + +唯一的实现(hive,`HiveConnectorMetadata.java:931`)没有任何 try/catch, +远端列举失败会直接向上抛。而且这不是疏忽——同一个方法的注释(`:922`)明确写道: + +> 这里的列举错误会**向上传播**(不同于 `estimateDataSizeByListingFiles` 的尽力而为 -1): +> 它支撑的是一条显式的 `ANALYZE ... WITH SAMPLE` 命令,历史实现也是让命令响亮地失败, +> 而不是让采样器把缩放因子静默塌缩成 1.0。 + +fe-core 的调用点(`PluginDrivenExternalTable.java:1077`)同样没有兜底 try/catch。 + +两边都写了详细理由,但结论相反。**实现方的理由更站得住**(用户显式发起的 `ANALYZE` 应该失败得明确, +而不是静默产生错误的统计值),所以应该修改的是接口文档。 +但只要这个矛盾还在,任何新连接器的作者都会照着接口文档去吞异常,从而引入静默的统计错误。 + +#### H2. `ConnectorContractValidator` 的执行方式与注释不符 + +见 G2:注释声称由各连接器的契约测试调用,实际只有一个用假连接器的 fe-core 测试。 + +> **本小节整条作废**(复核 `53a753e0a1b`)。两个理由:一,前提在调研日就是错的(当时已有四个真实连接器的契约测试在调,见 G2 的交叉核对修正);二,类注释已经在 `29b46660ac4` 里补上了"Actual coverage today"段落,如实写明谁在调、以及 hive 那个缺口。**这里不再有"实现与文档矛盾"。** + +#### H3. `listPartitionValues` 的用途与注释不符 + +见 C3:注释声称被 `partition_values()` 表函数使用,实际该表函数不走这条路径,引擎侧零调用。 + +> **本小节已失去对象**(复核 `53a753e0a1b`):方法连同它那段错误注释一起被 `891709a08e7` 删除了。 + +#### H4. `ConnectorScanRangeType` 的用途与注释不符 + +见 C1:两处注释都声称"引擎据此决定生成哪种 Thrift 扫描分片结构",实际引擎从不读取, +但 7 个连接器都实现了。 + +> **本小节已失去对象**(复核 `53a753e0a1b`):整族被 `da60bfadbac` 删除。另,是 **8 个**连接器不是 7 个(漏了 trino),见 C1 的交叉核对修正。 + +#### H5. `ConnectorProvider.create()` 违反父接口契约 + +> **状态(复核 `53a753e0a1b`):仍然成立,一步未动。** 那个无参 `create()` 仍然是"实现了就为了抛异常"。 + +`ConnectorProvider extends PluginFactory`,而 `PluginFactory` 要求实现无参的 `create()`。 +`ConnectorProvider` 的做法是(`ConnectorProvider.java:93`): + +```java +@Override +default Plugin create() { + throw new UnsupportedOperationException( + "ConnectorProvider does not support no-arg create(). " + + "Use create(Map, ConnectorContext) instead."); +} +``` + +注释坦承"提供它只是为了满足 `PluginFactory` 契约"。 +这是典型的"继承了一个不适用的接口"——任何按 `PluginFactory` 泛型处理插件的代码, +碰到连接器插件都会在运行时炸掉。而且 `PluginFactory` 还有一个 `create(PluginContext)`, +其默认实现就是委托给无参 `create()`,所以这条路径同样会抛异常,问题面比表面看到的更大。 + +**建议**:把 `PluginFactory` 中真正共用的部分(`name()` 等)抽成一个更小的父接口, +`ConnectorProvider` 只继承那个;或者让 `PluginFactory` 的 `create()` 变成可选的。 + +--- + +## 五、改进建议汇总(按优先级) + +> ### ⚠ 先读这张总账(复核 `53a753e0a1b`,2026-07-26) +> +> 下面 17 条是**调研日**(`7ff51a106f0`)写下的原文,逐字保留。此后 44 个提交里有相当一部分正是它们的落地,**照原文动手会重复劳动、扑空、或改坏正在跑的功能**。请先按这张总账过一遍。 +> +> | # | 复核状态 | 说明 | +> |---|---|---| +> | 1 | **已完成** | 白名单整体删除;且本条提的"反向名单"也已存在(三个内建类型名成为保留字) | +> | 2 | **仍开放** | 两处展示名 switch 原样;后续一轮审过并判定暂不下沉,属已知未决 | +> | 3 | **已完成** | 按表能力已类型化,载体是表 schema 上的正式字段。**⚠ 别再照原文加 `getTableCapabilities(session, handle)`,那会造出第二条并行通道** | +> | 4 | **部分完成** | 分片类型枚举族已删、`ConnectorPartitionHandle` 已删;**`estimateScanRangeCount` 没删,仍是零调用的死接口**——整条勾掉会漏掉它 | +> | 5 | **已完成** | 两项都选了"删除":属性描述子系统、`listPartitionValues` | +> | 6 | **部分完成** | H2 作废(前提在调研日就是错的)、H3/H4 失去对象;**H1 一步未动,且已升级为三方矛盾——这是全文最便宜、最该先做的一条** | +> | 7 | **应收窄** | 调研日就已有四个连接器的契约测试在调。真正要补的只有 **hive** 一家(它是"分区哈希写"的唯一声明者)。hudi / paimon / trino 不声明任何被检查的能力位,补了也只是恒真断言 | +> | 8 | **部分完成** | REST 直通已抽成窄接口;**SQL 直通那两个仍挂在 `ConnectorTableOps` 上**,且它们现在是该聚合接口仅剩的两个方法 | +> | 9 | **部分完成** | `getSerializedTable` 已随扫描属性键契约集中而消失;`isPartitionValuesSysTable`、`isNativeReadRange` 仍在,引擎仍在直接读 | +> | 10 | **⚠ 两半都撤销** | 常量走的是"原地中立化改名 + 去重"而不是下沉,**按原文去 hive 连接器找会扑空**;`adjustFileCompressType` 的 Hadoop 段落是有意保留的正面示范。见 B 段交叉核对修正 ① 与 ② | +> | 11 | **⚠ 大部分撤销** | `validateAndResolveDriverPath` 不是 JDBC 专有,iceberg 与 paimon 都在用;照做会打断这两个连接器。只有 `computeDriverChecksum` 是 jdbc-only | +> | 12 | **部分完成** | `ConnectorTableOps` 已按域拆分;**`ConnectorStorageContext` 从未创建**(高危、已单独立项);缓存失效那 4 个方法仍在 `Connector` 上 | +> | 13 | **仍开放** | `planScan` 的 4 个重载原样,"一个方法 + 一个请求对象"没有做 | +> | 14 | **已完成** | 走的正是本条推荐的方案:取消转发方法、统一走 `getWritePlanProvider(handle)`,对称性缺口在结构上不再可能存在 | +> | 15 | **部分完成 + 一半撤销** | 三态编码里 `getWritePartitioning` 那条本就不成立(只有两态),写排序列那条仍在;`getWriteContext()` 未改名(引擎侧已用新名,两名跨边界并存);`getLength()` 单位未澄清;**给同名反向失效接口改名这半撤销**——推模型已整套删除,冲突不存在 | +> | 16 | **部分完成** | 表级能力已类型化、主键那个键已删;分区列 / 分桶列 / 三个已渲染 SQL 子句的键仍在 | +> | 17 | **部分完成 + 一半撤销** | 规则已写进两份包级说明,但两半**未统一**(spi 的 thrift 回避被记为局部约定,是否统一明确列为不在范围内);**"修掉内联全限定名"这半撤销**,它已被逐字写进权威清单 | +> +> **合计**:完全完成 4 条(1 / 3 / 5 / 14)、部分完成 8 条(4 / 6 / 8 / 9 / 12 / 15 / 16 / 17)、仍开放 2 条(2 / 13)、应收窄 1 条(7)、应撤销 2 条(10 / 11)。 +> +> 另外,本清单**没有覆盖**到、但复核确认仍然开放的还有:E2 的读事务生命周期矛盾、F5 的两个属性命名空间静默合并、C6 的建库布尔位与建库方法手工同步、H5 的无参 `create()` 只为抛异常。 + +### 第一优先级:解除"新增连接器必须改 fe-core" + +1. 把 `CatalogFactory.SPI_READY_TYPES` 白名单改为"由插件注册表决定",迁移期若需要保留强制内建能力则改成反向名单。 +2. 把 `PluginDrivenExternalTable` 的两处按类型 switch 改为从 `ConnectorProvider` 取展示名, + 已有连接器各自返回历史名字,保证零行为变化。 +3. 补齐唯一真正缺失的扩展机制:**按表能力**应有正式接口方法,替换目前的 CSV 字符串键。 + +### 第二优先级:删除死接口,修正错误文档 + +4. 删除:`ConnectorScanRangeType` + `getRangeType()` + `getScanRangeType()`(顺带减少 7 个连接器的无效实现)、 + `estimateScanRangeCount`、`ConnectorPartitionHandle`。 +5. 处置:`ConnectorPropertyMetadata` + `Connector.getTableProperties()/getSessionProperties()`(接线或删除)、 + `listPartitionValues`(确认后删除或修正文档)。 +6. 修正 4 处错误文档(H1–H4),其中 H1 应改接口文档而非改实现。 +7. 让每个连接器的契约测试真正调用 `ConnectorContractValidator`。 + +### 第三优先级:中立化 + +8. 把 `executeRestRequest`、`executeStmt` + `getColumnsFromQuery` 移出通用接口, + 改为窄的可选接口 + `instanceof`(照抄 `RewriteCapableTransaction` 的现成模式)。 +9. `isPartitionValuesSysTable`、`getSerializedTable`、`isNativeReadRange` 三个方法的信息 + 分别可以由现有的系统表接口、`nodeProperties`、`appendExplainInfo` 承载,予以吸收。 +10. `HIVE_DEFAULT_PARTITION` 常量下沉到 hive 连接器;`adjustFileCompressType` 的文档去 Hadoop 化。 +11. `ConnectorValidationContext` 里的驱动包校验方法拆到 JDBC 专用的校验上下文。 + +### 第四优先级:结构与语义 + +12. 拆分 `ConnectorTableOps`(按 E1 的 7 组),拆出 `ConnectorStorageContext`, + 把缓存失效从 `Connector` 拆成可选接口。 +13. `planScan` 的 4 个重载合并为"一个方法 + 一个请求对象"。 +14. 补齐或取消写特性的按表变体(G1),推荐取消转发方法、统一走 `getWritePlanProvider(handle)`。 +15. 消除 `null` vs 空集合的三态编码;`getWriteContext()` 改名为 `getStaticPartitionSpec()`; + 澄清 `getLength()` 的单位;给两组同名反向的失效接口改名。 +16. 把 `__internal.` 保留键中的结构化信息提升为 `ConnectorTableSchema` 的正式字段, + 三个 SQL 渲染键改为结构化描述 + fe-core 渲染。 +17. 明确并写下 Thrift 中立性规则(api 与 spi 分别适用什么),修掉 `buildTableDescriptor` 的内联全限定名。 + +### 建议同时补充的一份文档 + +> **状态(复核 `53a753e0a1b`):已落地**(`d572751af49`)。两个模块各写了一份包级说明。本节点的四件事里,能力声明的机制选择规则(规则一)、中立性红线、thrift 使用边界(规则三)都写了;"一个最小连接器需要实现哪几个方法"改用另一种更强的形态兑现——按域拆分之后**每个域接口各自写了自己的最少实现集**,并配了一个标注必须实现的注解。 + +以上很多问题的根源是**没有一份"新增连接器指南"**。 +建议在 `fe-connector-api` 的 `package-info.java` 里写清楚四件事: + +- 一个最小连接器需要实现哪几个方法(应该是很短的一个列表); +- 能力声明的机制选择规则(见 D); +- 什么可以进公共 api、什么必须留在插件里(中立性红线); +- Thrift 类型的使用边界。 + +--- + +## 六、附录:核对方式说明 + +- "谁实现了某个方法":对 8 个连接器模块的 `src/main` 做符号级检索后逐一确认。 +- "引擎是否调用":在 `fe-core/src/main` 做调用点检索,排除测试目录与 `target` 目录; + 对判定为"零调用"的 5 处,另做了一次全仓(排除 `target`)复核。 +- "文档与实现是否一致":对接口文档中出现"必须 / MUST / 引擎会……"的断言,逐条回到实现与调用点核对。 +- 本文所有行号基于调研当日的工作区状态(分支 `catalog-spi-review-19`)。 + +本文未覆盖的部分(如需要可后续补充): +下推表达式体系(`pushdown` 包 17 个类)、DDL 变更描述对象(`ddl` 包 12 个类)、 +MVCC 数据结构(`mvcc` 包 5 个类)的内部设计;这三组主要是值对象,中立性上没有发现明显问题, +但 `ConnectorType`(7 个构造函数)和 `ConnectorPartitionInfo`(6 个构造函数)存在明显的构造函数堆叠, +建议改用构造器模式,这一点可以并入第四优先级一起处理。 + +> **就地重算**:原文这里写的是 `ConnectorPartitionInfo` 有 8 个构造函数,实测调研日与复核日都是 **6 个**(`ConnectorType` 的 7 个是对的)。两个数字在复核日均未变化,这条建议本身仍然成立、也仍然没有做。 diff --git a/plan-doc/connector-public-interface-cleanup/HANDOFF.md b/plan-doc/connector-public-interface-cleanup/HANDOFF.md new file mode 100644 index 00000000000000..e784a5d737b0ff --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/HANDOFF.md @@ -0,0 +1,377 @@ +# 🤝 交接文档 · 连接器公共接口整治 + +> **滚动文档**:每轮结束后**覆盖式更新**,只保留下一个 session 必须的上下文。已完成工作的明细不落这里(在 `git log` 与各任务文档里)。 +> **范围** = 把 `fe-connector-api` / `fe-connector-spi` 两个公共模块的接口设计规范化。 +> **⚠ 与主线互不覆盖**:catalog SPI 迁移主线的交接文档是 `plan-doc/HANDOFF.md`(当前跟的是另一条线)。**不要用本文覆盖它,也不要用它覆盖本文。** + +--- + +## 🆕🆕🆕 最新一轮(2026-07-27 第三次):rebase 到上游 `e48a96ae488` —— 0 冲突,但挖出一个构建口径错误 + 一批旧伤 + +`git pull --rebase upstream-apache branch-catalog-spi`,**78 个提交全部重放,0 个文本冲突**, +`range-diff` **78/78 全 `=`**(逐字节未变),上游 tip 已是 HEAD 祖先。备份 tag `backup-before-rebase-0727b` = rebase 前 `5aa18d41fb6`。 + +### 为什么这次一个冲突都没有 + +上游这轮 = 把整条栈 rebase 到 master `042e613b134`(带进 6 个 master 提交)+ **1 个自有提交** `e48a96ae488` +(port #65955 的 paimon table-option 透传 + `paimon.doris.*` → `paimon.jni.*` 改名)。 +上游 rebase 自身只改写了 3 个提交(paimon 迁移 / paimon 去 legacy / kerberos 合并),且差异**全在被删除的 fe-core paimon 代码**里——净树无影响。 + +**上游净 FE 改动全部落在 `fe-connector-paimon` 一个模块**。我方 78 个提交与上游净 delta 的文件交集(已做 `--find-renames`,我方有 1 个重命名 +`JdbcQueryTableValueFunction` → `PluginDrivenQueryTableValueFunction`)只有 5 个 paimon 文件, +而两边在这 5 个文件里改的**都不是相邻行段**——所以三方合并逐 hunk 各自落位,一个冲突都没有。 + +### ⚠ 但「0 冲突」正是最该查的情形,不是可以放心的信号 + +两边同文件不相邻改动,恰恰合得出「能编译但语义错」。上游自己这轮就踩过:#65955 的 `doris.*` → `jni.*` 改名是**静默断裂** +(FE 还发 `doris.*`、BE 只读 `jni.*`,编译/合并/测试全绿而 IOManager 永不生效)。所以按四层验证逐层过: + +1. **结构**:`range-diff` 78/78 `=`;上游 tip 是 HEAD 祖先。 +2. **重叠定位**:逐文件比对「上游新增行」∩「我方删除行」= **8 个文件全 0**,即我方补丁一行上游新代码都没冲掉。 + 人工复核三处接缝均完好:`BACKEND_PAIMON_JNI_OPTIONS` 已是 `jni.*` 三键且不含已退休的 `enable_file_reader_async`; + `PaimonConnector` 两处 `CatalogBackedPaimonCatalogOps(ensureCatalog(), tableOptions)` 都带上了 tableOptions; + `PaimonCatalogFactory` 排除项 + `PaimonConnectorProvider.validateProperties` 的 fail-fast 都在。 + 另核 **overlay 唯一漏斗**:全连接器除注释外无第二个 `catalog.getTable(` 调用点,我方 `PaimonConnectorMetadata` 的 −55 行是删死接口,不碰装载路径。 +3. **符号级**:FE 全反应堆 74 模块 `clean install` **BUILD SUCCESS**(含测试源)。 +4. **语义级**:见下面的 e2e 核对。 + +**e2e 静态核对**:上游新增/改动的 4 个 suite 都不含 `ENGINE=`/`SHOW CREATE` 基线, +不受我方「连接器自报 engine 名」改动影响;`test_paimon_ctas_atomicity_negative` 里的 `create table ... engine=paimon` +经核 `PaimonConnectorProvider.acceptedCreateTableEngineNames()` = `{"paimon"}`,仍然接受。 + +### 🔥 构建口径错误(**已知坑复发**,本轮白烧 2 个循环) + +先说结论:**全反应堆 `clean test-compile` 和 `mvn test` 对本仓库都是无效验证**,会假报失败。 +`fe-connector-hms-hive-shade` 是**零 java 源文件**的纯 shade 装配模块,`shade`/`jar` 目标绑定在 **`package` 阶段**。 +到不了 `package` 时,reactor 就把它的 `target/classes`(只有 `META-INF`)喂给下游、**盖住 `~/.m2` 里那个好的 shaded jar** ⇒ +- `test-compile`:`fe-connector-hms` 报一片 `cannot find symbol: IMetaStoreClient / HiveConf / LockComponent / Table / FieldSchema / Partition`; +- `test`:测试**发现期**炸 `NoClassDefFoundError: MetaException`(控制台只说 `TestEngine with ID 'junit-vintage' failed to discover tests`, + 真因只在 `target/surefire-reports/*-jvmRun1.dump` 里)。 + +⇒ **必须用 `install`**:符号级验证 `clean install -DskipTests`(`-DskipTests` 仍然**编译**测试源),跑测试也用 `install`,不要用 `test`。 + +**⚠ 教训(本轮真正的浪费)**:这不是新坑,memory `doris-build-verify-gotchas` 的 **#13(b)(c) / #15 / #20** 早就逐条记过, +#15 甚至给了另一条解法(`-pl '!fe-connector/fe-connector-hms-hive-shade,!fe-connector/fe-connector-paimon-hive-shade' test-compile`)。 +我这轮**没有先查 memory 就直接上 `test-compile`**,两次假失败各花一个完整构建循环。 +**下轮起步:动手跑任何 maven 前先读 `doris-build-verify-gotchas`。** + +### 旧伤:`fe-connector-hive` 13 个失败(**非本次 rebase 造成**,已修) + +判定方法(不猜):pre→post rebase **整树差异 = 上游 26 个文件**,其中**没有任何 fe-connector-hive 输入**; +且 `HiveConnectorMetadata.java` 与两个测试文件 **sha256 逐字节相同**,pre-rebase 树里就已含那条拒绝 ⇒ 确定性同结果。 +根因同一类:**校验从 fe-core 下放进连接器后跑得更早,fixture 却是按旧顺序写的**。已在 `784d11dafd2` 修(**只动测试,不碰生产**): + +- **NOT NULL(12 例)**:#65893 把「hive 不支持 NOT NULL」搬进 `validateColumns`,排在 createTable 所有校验最前; + 共享 `request()` fixture 的 `id` 是 `nullable=false` ⇒ 12 个用例还没走到自己要断言的 transactional / RANGE / 显式分区值 / 分桶拒绝就先被挡掉 + (表现为消息断言 `expected: but was: `)。改为 nullable;NOT NULL 规则本身仍由 `notNullColumnIsRejected` 覆盖。 +- **无子类型复杂类(1 例)**:`0ff75bb52cb` 已把复杂类型改成强校验,裸 `ConnectorType.of("ARRAY")` 现在构造即抛; + `complexPartitionColumnIsRejected` 要的是「COMPLEX 分区列被拒」,不是「类型造不出来」⇒ 给 `col()` 加 `ConnectorType` 重载,传 `ARRAY`。 +- **分区列不存在(1 例)**:#65893 也把分区键存在性检查搬进了连接器;`createTableListPartitionThreadsPartitionKeys` + 按 `dt`+`region` 分区而 fixture 只有 `id`+`dt` ⇒ 自带列表并把 region 放末尾(hive 要求分区列在 schema 末尾)。 + +### 测试结论(全绿) + +`fe-connector` 聚合 **16 个模块全部 SUCCESS,checkstyle 开着,2939 个测试 0 失败 0 错误**(7 个 skip 是既有 live-connectivity)。 +其中 `fe-connector-paimon` **401/401**(rebase 真正影响的模块)、`fe-connector-hive` **375/375**、`fe-connector-iceberg` 1151、 +`fe-connector-api` 110(含录制基线 `ConnectorMetadataSurfaceTest`)。 +fe-core 定向 59/59(`CacheHotspotManagerTableFilterTest` 36 —— 正是上游 #66081 那个 feut 修复、 +`ConnectorPluginManagerTest` 16、`FileSystemPluginManagerTest` 5、`MetadataGeneratorPluginDrivenTest` 2)。 +**BE 未编译**:上游本轮 BE 改动(`scanner_context`、`paimon_jni_reader` ×2、`leak_annotations`)与本分支 BE 文件集合交集为空。 +**e2e 未跑**(需集群)。**未 push**。 + +--- + +## 🆕🆕 上一轮(2026-07-27 第二次):rebase 到上游 `ceb33843d4b` —— 2 个真冲突,都在同一个文件 + +`git pull --rebase upstream-apache branch-catalog-spi`,**75 个提交全部重放**,`range-diff` **73 个 `=` / 2 个 `!`**, +两个 `!` 正是我手工解的那两个提交(第 13 个 `remove the catalog type allow-list`、第 49 个 +`let each catalog answer for its own CREATE TABLE engine name`),其余 73 个补丁**逐字节未变**。 + +**上游这轮做的事** = 把整条 catalog-spi 栈(63 个提交)**rebase 到 master `5b3ac63f8b4`**,带进 5 个 master 提交: +`#66073` parquet V2 列惰性初始化、**`#65987`** JDBC driver_url 加固 + 删文件上传 HTTP API、 +**`#65644`** `information_schema.plugins` 插件清单、`#65492` RowKeyEncoder、`#66053` CODEOWNERS。 +外加 1 个自有 doc 提交。**后两个加粗的才是冲突源**——其余三个不碰 FE 连接器层。 + +### 冲突根因:两边在**同一段循环体**里各自加了一道关 + +两处冲突都在 `fe-core/.../connector/ConnectorPluginManager.java`,且都不是「谁对谁错」,是**两道正交的关叠在同一行**: + +| 位置 | 上游(`#65644`)加的 | 本分支加的 | +|---|---|---| +| `loadBuiltins()` 的 lambda | `PluginRegistry.registerBuiltin` 登记清单行 + `try/catch` 兜住**插件自报元数据抛异常** | `registerDiscovered(p, true)`:类型名非空 / 不撞引擎内建 / 不重名,classpath 批**失败即抛** | +| `loadPlugins()` 成功循环 | `hasProviderNamed` 挡同名目录 jar + `discard` + `registerExternal` 登记清单行 | `registerDiscovered(handle.getFactory(), false)`:同样的关,目录批**跳过并记日志** | +| 常量区 | `PLUGIN_FAMILY = "CONNECTOR"` | `RESERVED_CREATE_TABLE_ENGINE_NAMES` | + +### 解法:取并集,且**收窄 try 的范围**(这是唯一有语义分量的决定) + +```java +try { + PluginRegistry.getInstance().registerBuiltin(PLUGIN_FAMILY, p); // 只包这一句 +} catch (RuntimeException e) { LOG.warn(...); return; } +if (registerDiscovered(p, true)) { LOG.info(...); } // 故意留在 catch 之外 +``` + +**为什么必须收窄**:`registerDiscovered(p, true)` 对重名/保留名是 `throw IllegalStateException`(本分支有意的 +fail-loud,classpath 撞名 = 构建错误)。若照抄上游把它一起包进 `catch (RuntimeException)`,**fail-loud 会被静默 +降级成 warn+skip**——能编译、能启动、测试全绿,但那道关等于没了。这是本轮唯一会「静默出错」的点。 + +`loadPlugins` 侧把 `registerExternal` 放进 `if (registerDiscovered(...))` **之内**:上游兄弟类 +`FileSystemPluginManager` 自己写着不变式「a provider must never be active without its inventory row +(**or vice versa**)」——被拒的插件不该在 `information_schema.extensions` 里露脸。 + +另:`PluginRegistry.register()` 对重复/非法名是 **返回 false 而不抛**,所以上游那个 `catch` 只兜插件自身 +`name()`/`description()` 抛异常的情况;合并后这一语义**逐字保留**。 + +### 验证(四层,全绿) + +① **结构**:`range-diff` 73`=`/2`!`,75/75 在位、顺序不变。 +② **树级重叠**:`git diff pre-rebase-20260727 HEAD` = **恰好 57 个文件**,与上游净改动文件集**完全相等** + (既没多出东西 = 三方合并没偷偷改我的代码,也没少 = 我的重放没回退上游的改动)。 +③ **符号级**:全反应堆 `test-compile` **BUILD SUCCESS**(2:57)。 +④ **行为级**:`fe-connector-api` **110/110**(含录制基线 `ConnectorMetadataSurfaceTest`,说明上游没动 SPI 表面)、 + `fe-connector-jdbc` **214/214**(含上游新增的 `JdbcDriverUrlSecurityRuleTest`)、 + `fe-extension-loader` **10/10**(上游新增的 `PluginRegistryTest` 7 + `DirectoryPluginRuntimeManagerMetadataTest` 3)、 + fe-core 定向 **52/52**(`ConnectorPluginManagerTest` 16 / `CatalogFactoryPluginRoutingTest` 5 / + `FileSystemPluginManagerTest` 5 / `MetadataGeneratorPluginDrivenTest` 2 / `SchemaTableTest` 2 / `JdbcResourceTest` 22); + fe-core checkstyle **0**。**e2e 未跑(本地无集群),仍是欠账。** + +另外两个「两边都改了但自动合并成功」的文件已逐项核对双方改动都在:`MetadataGenerator.java` +(我的 `ConnectorPartitionValues.NULL_PARTITION_NAME` + 上游的 `EXTENSIONS` 分支)、 +`JdbcDorisConnector.java`(我的 `sanitizeOutboundUrl` / 摘掉 `SUPPORTS_PASSTHROUGH_QUERY` + 上游的 +`checkDriverUrlSecurityRule`)。反向悬空引用也查了:`sanitizeJdbcUrl` 全仓 0 命中, +上游删掉的 `UploadAction` / `LoadSubmitter` / `TmpFileMgr` 本分支 0 引用。 + +### ⏭ 留给下一轮的两条 + +1. **未被测试压住的 6 行**:`loadBuiltins` / `loadPlugins` 这两段循环体**两边都没有测试** + (`ConnectorPluginManagerTest` 的 16 个用例全是直接调 `registerDiscovered`,不走这两个入口; + 要压住得往 fe-core 测试 classpath 塞 `META-INF/services` 或造插件目录,代价远超收益)。 + **本轮靠对照兄弟类 `FileSystemPluginManager` 逐行读来确认**,不是靠测试。改这两段时要知道这点。 +2. ~~**`discard` 不对称**~~ **已补齐(用户拍板)**:合并后 `loadPlugins` 的两条拒绝路径现在都调 + `runtimeManager.discard(handle.getPluginName())` 关掉插件 classloader,对齐 `FileSystemPluginManager` + 「每条拒绝路径都 discard」的惯例。**安全性依据**:`registerDiscovered` 返回 `false` 时 + `claimedTypes.add` 被 `problem == null &&` 短路(或本就返回 false = 名字是别人占的), + `claimedEngineNames` 未写、`providers` 未加 ⇒ 管理器里没有任何东西引用那个 classloader,关掉是安全的; + `discard` 自身 `handlesByName.remove` 后判空,可重入。不额外打日志——`registerDiscovered` 已经 + `LOG.error` 过拒绝理由,再记一条就是重复。 + +--- + +## 上上上轮(2026-07-27):rebase 到上游 `1aa5ae9597e` —— 「零冲突」但树是坏的 + +`git pull --rebase upstream-apache branch-catalog-spi`,**72 个提交全部重放,0 个文本冲突**, +`range-diff` 71 个 `=` / 1 个 `!`(第 28 个 `centralize the scan-node property key contract`, +差异只在 paimon 那段 javadoc 上下文——上游已先把 cpp 臂删了)。 + +上游这轮做了两件事:rebase 到 master `962bd5b28c7`(带进 #66008 / #66036 / #66021),外加两个自有提交 +(`port #66008's paimon-cpp removal to the paimon connector scan path`、 +`dedupe partition columns so a multi-transform spec cannot crash partition pruning`)。 + +**零文本冲突 ≠ 树是好的。** 本轮实测两处坏点,都不会以冲突形式出现: + +1. **(rebase 造成)** 上游新增的 `PaimonScanPlanProviderTest.cppReaderSessionFlagNoLongerChangesThePlan` + 调的是**七参** `planScan`,而本分支第十四批已把它换成 `planScan(session, ConnectorScanRequest)`。 + 两边**没碰同一行** → 三方合并各取一半 → 只有 `test-compile` 报 + "method planScan cannot be applied to given types"。已按请求对象逐字等价移植(builder 默认值 + = 上游传的那七个参数),提交 `b8935724788`。 +2. **(不是 rebase 造成,早就红着)** `ConnectorMetadataSurfaceTest` 因为第十三批 + (`executeStmt` / `getColumnsFromQuery` 搬去 `ConnectorPassthroughSqlOps`)和 + `drop the create-database mirror switch`(删 `supportsCreateDatabase()`)**没同步基线资源**而失败。 + `ConnectorMetadata.java` 与 `connector-metadata-methods.txt` 在 rebase 前后**逐字节相同** → 证明与 rebase 无关。 + 已刷新基线,提交 `c556938541b`。 + +**⚠ 下一个 session 的教训(这条最值钱)**:之所以 (2) 一直没被发现,是因为历次验证只跑 +「全反应堆 test-compile + 本批改到的连接器模块」,**从没跑过 `fe-connector-api` 自己的测试套件**。 +改了 `fe-connector-api` 的公共接口,就必须跑 `fe-connector-api` 的测试——`ConnectorMetadataSurfaceTest` +是全仓**唯一**的「录制基线」测试(`find fe/fe-connector -path '*/src/test/resources/*' -name '*.txt'` 只有一个命中), +删/加/改 SPI 方法签名必须在**同一个提交**里刷新 `src/test/resources/connector-metadata-methods.txt`。 + +**本轮验证**:全反应堆 `test-compile` BUILD SUCCESS(75 模块 / 0 error); +`fe-connector-api` 110/110、`fe-connector-spi` 9/9、`fe-connector-paimon` 390(0 失败,1 个既有 +live-connectivity skip)、`fe-connector-iceberg` 1151(0 失败,5 个既有 live-endpoint skip)、 +`fe-filesystem-api` 75/75、`fe-connector-hms-shared` 104/104;fe-core 分区裁剪相关 6 个类 110/110 +(上游这轮改了 `PruneFileScanPartition`);两个改动模块 checkstyle 各 0 violation。 +**e2e 未跑(本地无集群)**,仍是欠账。 + +--- + +## 🔥 构建命令(照抄,别用更早版本的写法) + +```bash +# ① 全反应堆含测试源编译 + 跑指定测试(checkstyle 摘出去) +mvn -o -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml \ + -pl '!fe-connector/fe-connector-hms-hive-shade,!fe-connector/fe-connector-paimon-hive-shade' \ + -Dmaven.build.cache.enabled=false -Dcheckstyle.skip=true -T1C \ + test-compile test -Dtest='<类名清单>' -Dsurefire.failIfNoSpecifiedTests=false + +# ② checkstyle 只对本次真正改动的模块单独跑 +mvn -o -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml \ + -pl <改动的模块清单> -Dmaven.build.cache.enabled=false checkstyle:check +``` + +`-pl` 缩到单模块对 `checkstyle:check` **安全**;对 `compile` / `test-compile` **不安全**。 + +**对 `test` 同样不安全,且失败方式很误导**(2026-07-27 实测):`-pl fe-connector/fe-connector-paimon` 不带 +`-am` 时,兄弟模块从 `~/.m2` 取**陈旧 jar**,surefire 在**测试发现阶段**就炸,报的是 +`TestEngine with ID 'junit-vintage' failed to discover tests` + `Tests run: 0`,真因要翻 +`target/surefire-reports/*.dump` 才看得到(`NoClassDefFoundError: .../ConnectorScanRequest`)。 +**要么全反应堆,要么 `-pl <模块> -am`。** + +--- + +## 🆕 下一个 session 起步 + +**必读顺序**:本文 → [README.md](./README.md) 的任务清单。 +**不要通读** `audit-report.md`(1600 余行),按 README 里的章节导航 grep 定位。 + +**当前状态:十四批已合入(共 57 个提交)。25 个编号任务 + 「复核登记的开放项」10 条全部完成。** + +### 下一步:只剩需要集群的事 + +代码侧这条工作线已经做完。剩下的全部是**积压的端到端与插件包重部署冒烟**(见下方欠账清单),本地无集群一律跑不了。 + +如果要继续在代码上找事做,下面这些是本轮顺带记下的、**没有人认领**的候选(都不是这条线的欠账): + +- `streamSplits` 与 `getScanNodeProperties` / `getScanNodePropertiesResult` 仍是位置参数(5 参 / 4 参)。它们**没有重载链**,所以没有 `planScan` 那种「实现了却静默失效」的陷阱,本轮刻意没动。真要统一风格可以让它们也接 `ConnectorScanRequest`。 +- 引擎构造扫描请求时若漏掉 `.countPushdown(...)`,COUNT(*) 下推会静默退化成全量扫描,而 fe-core 侧**没有任何单测**压这个调用点(连接器侧有 paimon/iceberg 的 countPushdown 用例,但它们直接调 `planScan`)。这不是本轮引入的,改造前那个位置参数同样没被压住。 +- 逐个连接器接入 `renderShowCreateTableDdl`(今天只有 hive 在用),各自独立排期。 + +--- + +## 📌 第十四批落地后的事实变化(1 个提交) + +1. **`ConnectorScanPlanProvider.planScan` 只有一个方法了**:`planScan(ConnectorSession, ConnectorScanRequest)`。四个重载(4/5/6/7 参)全部删除。 +2. **新增下推信号不再新增重载**:往 `ConnectorScanRequest` 加一个带默认值的字段即可,所有连接器自动拿到,**不可能再出现「实现了最短的那个抽象方法、静默失去 limit / 分区裁剪 / COUNT 下推」**。 +3. **`planScanForPartitionBatch(session, request, partitionBatch)`**,默认实现走 `request.withRequiredPartitions(batch)`。hive 仍然覆写它(它的 `planScan` 不是按分区集作用域的,继承默认会每批重放整个裁剪集 → 重复行)。 +4. **请求对象的默认值就是「引擎没有额外要求」**:无过滤、limit = -1、分区集为空(= 扫全部)、无 COUNT 下推。`requiredPartitions` 传 `null` 与传空等价(引擎在裁剪到零时早就短路了,走不到这里)。 +5. **零行为变化**:每个连接器收到的值与改造前逐字相同;批模式那条路仍然是「无 limit、无 COUNT 下推」。 + +--- + +## 📌 第十三批落地后的事实变化(8 个提交) + +1. **`estimateScanRangeCount` 已删除**(SPI 默认 + jdbc 覆写),全仓复扫零命中。 +2. **`ConnectorTableOps` 现在真的什么都不声明了**,只是六个域接口的聚合。SQL 直通搬进新的 **`ConnectorPassthroughSqlOps`**(可选接口,jdbc 实现)。 +3. **`ConnectorCapability.SUPPORTS_PASSTHROUGH_QUERY` 已删除**。判据改为「metadata 是否 `instanceof ConnectorPassthroughSqlOps`」——**实现接口就是声明本身**,不再有能力位与实现两个答案。两个入口(`query()` TVF、`CALL EXECUTE_STMT`)都改成类型判定。 +4. **`ConnectorSchemaOps.supportsCreateDatabase()` 已删除**,`CREATE DATABASE IF NOT EXISTS` 的远端存在性预检改为**无条件**(对齐 Trino 的 `CreateSchemaTask`)。 + **⚠ 唯一的用户可见行为变化**:jdbc / es / trino / hudi 目录上 `CREATE DATABASE IF NOT EXISTS <远端已存在的库>` 由「报 CREATE DATABASE not supported」变成**静默成功**。两个问题都不回答的连接器(`databaseExists` 保持默认 false)行为不变。 +5. **`ConnectorWriteHandle.getWriteContext()` → `getStaticPartitionSpec()`**,与引擎侧产出方同名。 +6. **`JdbcQueryTableValueFunction` → `PluginDrivenQueryTableValueFunction`**(fe-core 里最后一个按数据源命名、实际服务任意连接器的类)。 +7. **`supportsCastPredicatePushdown` 现在没有任何连接器是「继承来的」**:iceberg / es / trino 就地声明 `true` 并写明各自拿这个谓词做什么、`true` 是接受风险而非安全声明;hive / hudi **对残余谓词零消费**(`planScan` 与 `getScanNodeProperties` 都不看),这个开关对它们是死的,因此不加空覆写,改在 SPI 文档里记下整张地图。零行为变化。 +8. **契约校验器的每条不变量都有真连接器正样本了**:maxcompute 压 local-sort 臂,新增的 `HiveConnectorContractTest` 压 hash 臂与两臂互斥。 + +--- + +## 📌 第十二批落地后的事实变化 + +1. **`ConnectorContext` 只剩 7 个方法 + 一个 `getStorageContext()`**(404 行 → 155 行)。新增存储服务加在 `ConnectorStorageContext`,**不要加回 `ConnectorContext`**。 +2. **钉桩失效的残留风险写在两处注释里**:今天没有任何存储方法跑插件代码;将来若有,钉桩子类必须覆写 `getStorageContext()` 返回自己的包装。 +3. **测试替身的静默退化是隐性风险**:替身实现了 `ConnectorStorageContext` 却忘写 `getStorageContext()` 时能编译过,覆写全变死代码。 +4. **`ForwardingConnectorContextTest` 是反射驱动的**,`ConnectorContext` 上加方法不加转发会直接构建失败并点名方法。 +5. **插件包必须与 FE 同版本部署**。混部表现为运行期 `AbstractMethodError`,不是启动期拒绝。 + +--- + +## 📌 第十一批 / 第十批落地后的事实变化(仍然成立) + +1. **外部表的引擎名由连接器说了算**(`ConnectorProvider.displayEngineName()`,默认取目录类型名,只有 MaxCompute 覆写)。`SHOW CREATE TABLE` 的 `ENGINE=` 与 information_schema 的 ENGINE 列取同一个值。 +2. **`displayEngineName()` 与 `acceptedCreateTableEngineNames()` 是两件事**:hms 目录**显示** `hms`、**接受** `ENGINE=hive`。 +3. **`CreateTableInfo` 里没有任何引擎名判定**,改按目标目录路由;外部目录的建表语句不带引擎名(null),这是刻意的。 +4. **`MODIFY ENGINE` 子系统已删除**,但**刻意保留** `ModifyTableEngineOperationLog` / `OperationType.OP_MODIFY_TABLE_ENGINE` / 重放分支——老镜像要能读。 +5. **引擎名从来没有被持久化过**,所以这条线不需要镜像版本号 / gson 迁移 / editlog 垫片。 + +--- + +## ⚠️ 做下一批之前必看 + +1. **`UnusedLocalVariable` 是开启的**(第十四批踩到):把方法参数改成「从请求对象解包成同名局部变量」时,只解包**真正用到**的那些——多解一个就是 checkstyle 失败。判断「用到了吗」别用裸 grep(hudi 的 `columns` 只出现在**别的方法**里,多解了一个)。 +2. **任务文档与登记表会过期,动手前必须按符号重侦察。** 第十三批又实证一次:登记表把「hive 契约正样本」「读事务矛盾」估成中等成本,实际都是低;而 `supportsCastPredicatePushdown` 登记的「五个连接器继承默认值」是错的——其中两个(hive/hudi)根本不消费残余谓词,那个开关对它们是死的。**别按登记表的成本估算排期,先看代码。** +2. **`git commit` 提交的是整个索引,不是你刚 `git add` 的那几个文件。** 第十三批踩到:更早的 `git mv` 把重命名留在索引里,被第一个提交顺手带走,留下一个**编译不过**的中间提交(文件名已改、类名未改)。修法是 `git reset --mixed HEAD~N` 后逐个重做。**每次提交前先 `git status --porcelain` 看索引里到底有什么。** +3. **改名类改动会撞行长上限**:`getWriteContext` → `getStaticPartitionSpec` 让 iceberg 一行超 120 字符被 checkstyle 挡。改名后一定要跑改动模块的 `checkstyle:check`。 +4. **`CustomImportOrder` 对新增 import 很敏感**:`sed` 插 import 时按字典序插,`ConnectorMetadata` 在 `ConnectorPassthroughSqlOps` 之前。 +5. **hive 连接器的单测不能碰 `getOrCreateClient()`**(会建真的 `ThriftHmsClient`,测试环境没有 Hadoop 栈)。要真实的写提供者就直接 `new HiveWritePlanProvider(null, props, ctx)`(构造是纯赋值),或匿名子类覆写 `getWritePlanProvider()`。 +6. **纯 Mockito mock 上的新方法默认返回 null / 什么都不做**。加 SPI 方法后必须查所有 mock 该接口的测试。 +7. **仓库有 60 余个顶层未跟踪项**(含明文密钥的配置、临时日志、workflow 脚本)。**严禁 `git add -A`**,一律显式路径。 +8. **删除类改动必须配全仓符号 grep + 清空 `test-classes` 后重跑**。 + +--- + +## 🧭 待用户拍板 + +完整清单在 **[open-decisions.md](./open-decisions.md)**。**已拍板三十四条**(第十三批新增四条:建库布尔位删除 / SQL 直通独立接口并删能力位 / `planScan` 收成请求对象 / CAST 下推保持 true 但逐连接器显式声明)。 + +**目前没有待拍板项。** + +--- + +## 🧾 顺带发现、留给后续批次 + +**第十三批新增**: + +- **`PluginDrivenQueryTableValueFunction.getScanNode()` 不做类型判定**:入口 `createQueryTableValueFunction` 已经拒过不实现直通接口的目录,所以这里直接建扫描节点。若将来有第二个入口能绕过工厂,这里要补判定。 +- **`CALL EXECUTE_STMT` 没有任何 e2e**(只有 jdbc 一家实现,仓库里查不到断言)。 + +**沿用的**(未变):ES 两个兼容 HTTP 端点的既有安全面(已拍板单独立项);EXPLAIN 与实际下推判据不一致(已拍板逐字保留);hudi 的 `\N` 渲染分歧(已拍板不统一);合成键 `nativeReadSplitNum` 在批模式恒 `0/0`;`EsScanRange.getFileFormat()` 死代码;`PluginDrivenScanNode.TABLE_FORMAT_TYPE` 零引用;`MetadataGenerator` 按字符串比较哨兵;`TablePartitionValues.toListPartitionItem` 哨兵不可达;`ConnectorContractValidator` 生产零调用方;时间旅行委派路径没有反射兄弟能力;两个只写不读的属性键;hudi `partition_values()` 可能落后一个缓存过期;es `REGEXP` 模式串直传 Lucene 少行;`ConnectorMvccSnapshotAdapter` 零引用死类;`CatalogFactory` 的 `lakesoul` 硬失败;`ConnectorSession.getStatementScope` 默认不记忆;两套残差协议未合并;逐个连接器接入 `renderShowCreateTableDdl`(今天只有 hive 在用,各自独立排期)。 + +--- + +## 🧪 欠下的端到端(本地无集群,一律标「待集群验证」,不得当作已通过) + +**第十三批新欠 1 条(唯一一条会改变用户可见行为的)**: + +**`CREATE DATABASE IF NOT EXISTS` 在不能建库的目录上**:拿一个 jdbc(或 es / trino / hudi)目录,对一个**远端已存在**的库执行 `CREATE DATABASE IF NOT EXISTS `,断言**成功且无输出**(改动前会报 `CREATE DATABASE not supported`);再对一个**不存在**的库执行同一条语句,断言仍然报 `CREATE DATABASE not supported`。两条都需要真集群。另需回归 jdbc 的 `query()` TVF 与 `CALL EXECUTE_STMT`(入口判定换成了类型判定)。 + +**第十二批新欠 1 类(最重的一条,仍未跑)**: + +**插件包重部署冒烟**——`mvn package` 取各连接器 `target/doris-fe-connector-.zip` → 清空并重新解包到 `connector_plugin_root` → 重启 FE 确认日志列出全部类型 → 跑下表七项 → 观察日志无 `ClassCastException` / `NoClassDefFoundError` / `AbstractMethodError`: + +| 冒烟项 | 覆盖什么 | +|---|---| +| iceberg 目录 `INSERT`(对象存储 warehouse) | 写路径 BE 文件类型 + 地址归一 + 静态凭证 | +| iceberg `DROP TABLE`(HMS 托管位置) | 空目录清理 | +| iceberg Kerberos 目录一读一写 | 钉桩与「连接器单一认证方」语义 | +| paimon REST 目录一次带临时凭证的扫描 | 临时凭证归一 + 批量地址归一器 | +| hive 分区表扫描 + `INSERT` | 引擎文件系统 | +| hudi 目录一次扫描 | BE 存储属性 + 地址归一 | +| `CREATE CATALOG … "test_connection"="true"`(iceberg + S3) | BE 连通性探测 | + +另需跑 jdbc 目录用例一遍。 + +**第十一批欠的 2 类**:3 个 `.out` 基线共 19 行已改写必须实跑(`test_nereids_refresh_catalog.out` → `ENGINE=jdbc`、`test_paimon_table_properties.out` → `ENGINE=paimon`、`test_max_compute_create_table.out` → `ENGINE=maxcompute` 需真实阿里云账号);trino-connector / max_compute 的 ENGINE 列不再是 NULL 但全仓零断言,值得补一条。 + +**第十批欠的**:7 处改写后的断言必须实跑(`test_iceberg_create_table.groovy:61,66,71` 与 `test_hive_ddl.groovy:442,478,727,732`,文案已改为 `Engine 'X' does not match catalog 'Y'.`);iceberg / paimon 带 `DISTRIBUTED BY` 建表的新文案全仓零断言;hive 打开 `enable_create_hive_bucket_table` 后的正向分桶建表用例仍缺。 + +**沿用**:ES 的六处 `terminate_after` 断言与两个 REST 端点 curl;iceberg `rewrite_data_files` 的五个套件;paimon 目录查询回归;hive 文本/CSV/JSON 表读回归;文件缓存准入 + `SWITCH ` + 事件同步预热;异构目录嵌套列 DDL 与 iceberg 表注释;异构 HMS 目录上的 `ANALYZE`/Top-N/嵌套列裁剪/`SHOW CREATE TABLE`。 + +--- + +## ⚙️ 其余构建与验证的坑(实测,直接复用) + +1. **maven build cache 会静默跳过测试执行**:跑测试一律加 `-Dmaven.build.cache.enabled=false`。 +2. **maven 一律用绝对路径 `-f`**;`cd` 会让后续相对路径失效。 +3. **`-Dtest='org.apache.doris.datasource.**'` 这种全包扫描会超时被砍**,用具体类名清单。 +4. **e2e(groovy)需要真集群,本地跑不了**。**没有 `.out` 基线的新用例不要用 `qt_`**。 +5. **`HiveConnectorMetadataDdlTest`(19 个里 12 红)与 `HiveCreateTableValidationTest`(10 个里 1 红)在本分支上本来就是红的**(建表路径),与本线改动无关。 +6. **全反应堆必须 `-Dcheckstyle.skip=true`**(checkstyle 扫 generated-sources 会退化成平方级,构建卡死 60+ 分钟),checkstyle 单独对改动模块跑。 +7. **`-pl <子集>` 跑测试会撞上 `~/.m2` 里的陈旧 jar**,一律用开头那条全反应堆排除式命令,或加 `-am`。 +8. **checkstyle**:方法名正则 `^[a-z][a-z0-9][a-zA-Z0-9_]*$`(第二个字符也必须小写);`CustomImportOrder` 按字典序;`UnusedImports` 强制;注释块前不得有连续两个空行;行长 120。 +9. **`mvn ... | tail -60` 会把 `Tests run:` 行冲掉**。一律 `> 日志文件 2>&1` 再 grep。 +10. **fe-core 测试里注私有字段用 `org.apache.doris.common.jmockit.Deencapsulation`(仓库自带)**。 +11. **`PluginDrivenExternalCatalog.getConnector()` 会触发 `makeSureInitialized()`**;`hasConnectorCapability` 同理。要在分析期读声明必须走 `ConnectorFactory.findProvider(type, props)`(provider 级,零远端)。 + +--- + +## 📈 进度记录 + +| 日期 | 做了什么 | 结果 | +|---|---|---| +| 2026-07-25 | 独立 clean-room 调研(14 个并行审查单元 + 30 批对抗复核) | 172 条结论成立/部分成立,4 条被推翻;产出 `audit-report.md` | +| 2026-07-25 | 建立本任务空间,按优先级拆出 25 个任务并各写一份施工文档 | 代码零改动 | +| 2026-07-25 | 第一批:07 + 08 + 10 | 第二批:11 号 | 第三批:15 号 | 第四批:01~06 | 逐批 `test-compile` + 单测 + 变异验证通过 | +| 2026-07-25 | 第五批:09 + 14 + 13 + 12 | 八个模块全量单测 634 个全绿;4 个变异全部被捕获 | +| 2026-07-26 | 第六批:21 + 16 | 第七批:17 | 第八批:20 + 22 + 19 | 第九批:25 | 逐批全绿;第七批定位并绕过了让构建卡死 60+ 分钟的 checkstyle 退化 | +| 2026-07-26 | 第十批:引擎概念下沉(5 提交) / 第十一批:展示引擎名交还连接器(3 提交) / 第十二批:引擎上下文存储服务拆分(2 提交) | 全反应堆 `test-compile` **BUILD SUCCESS**;变异全部如期变红;e2e 基线**待集群验证** | +| 2026-07-27 | **第十四批:扫描计划请求对象 1 个提交**(`ConnectorScanRequest` 新增 + `planScan` 四重载合一 + `planScanForPartitionBatch` 改签名 + 14 个连接器覆写塌成 8 个 + 引擎两个调用点 + 约 20 个测试文件) | 全反应堆 `test-compile` **BUILD SUCCESS**;26 个扫描相关测试类 **402 个测试全绿**;十个模块 checkstyle **0 违规**;2 个变异如期变红(`withRequiredPartitions` 丢过滤条件、批默认忘记按批重定作用域——正是这次改动唯二能静默出错的地方) | +| 2026-07-26 | **第十三批:开放项清理 8 个提交**(删死接口 `estimateScanRangeCount` / 三条契约文档澄清 / hive 契约校验正样本 / 写句柄改名 `getStaticPartitionSpec` / 直通 TVF 中立命名 / 删建库镜像开关 / SQL 直通独立成可选接口并删重影能力位 / CAST 下推逐连接器显式声明) | 全反应堆 `test-compile` **BUILD SUCCESS**;4 组共 22 个测试类全绿(其中一轮 258 个用例);九个模块 checkstyle **0 违规**;`CREATE DATABASE IF NOT EXISTS` 的行为变化**待集群验证**;`planScan` 重载合并**已拍板未动手** | + +**上下文用量超过 30% 就找一个干净节点覆写本文并通知用户开新 session 续做**,不要等窗口满。 diff --git a/plan-doc/connector-public-interface-cleanup/README.md b/plan-doc/connector-public-interface-cleanup/README.md new file mode 100644 index 00000000000000..e36d1bb049f887 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/README.md @@ -0,0 +1,193 @@ +# 连接器公共接口整治 · 任务空间 + +这个目录是一条**独立工作线**的全部资料:把 `fe-connector-api` 与 `fe-connector-spi` 这两个公共模块的接口设计规范化,使得以后新增一个连接器时,能够照着接口定义清晰地实现,并且**不需要修改 `fe-core`、`fe-connector-api`、`fe-connector-spi`**。 + +> **范围声明**:本目录只管这条线。catalog SPI 迁移主线的交接文档是 `plan-doc/HANDOFF.md`,两者互不覆盖。 +> **基线**:调研与全部任务文档的行号均以 `7ff51a106f0`(分支 `catalog-spi-review-19`)为准。**核对时以符号名为准,不要以行号为准。** + +--- + +## 一、怎么读这个目录 + +| 顺序 | 文件 | 用途 | +|---|---|---| +| 1 | **[HANDOFF.md](./HANDOFF.md)** | **每个 session 起步必读**。当前进度、下一步做什么、待拍板的决策、构建与验证的坑 | +| 2 | [audit-report.md](./audit-report.md) | 完整调研报告(1600 余行)。**不要通读**——按需 grep 定位章节 | +| 3 | 下面的任务清单 | 挑一个任务,去读它自己的文档 | +| 4 | `tasks/<编号>-<名字>.md` | 单个任务的施工说明书:背景、为什么是问题、最小例子、目标状态、改动清单、验证方式、风险 | + +**关于行号**:任务文档里指向**代码**的行号经过逐条抽查(约 510 处,绝大多数逐行命中),可以放心用来定位;但指向 `audit-report.md` 的引用请以**小节标题或条目编号**为准,那份报告后续还在增补、裸行号会漂移。核对代码时一律**以符号名为准**。 + +**每份任务文档都提出了「动手前需要先定的事」**。它们已汇总进 **[open-decisions.md](./open-decisions.md)**:25 份文档合计约 40 个决策点,多数有明确推荐值照做即可,其中 **4 个会改变行为或用户可见文案、必须先定**(已在该文档开头单列)。开工一个任务前先看它在那份清单里的条目。 + +调研报告的章节导航(用于回溯某条结论的完整证据): + +- 主题一(第四节):新增连接器今天必须碰公共模块的哪些地方 +- 主题二(第五节):能力声明的多条并行通道 +- 主题三(第六节):大接口的职责耦合 +- 主题四(第七节):死接口面清单 +- 主题五(第八节):数据源专有语义泄漏 +- 主题六(第九节):两套相反的 thrift 规则 +- 主题七(第十节):语义与契约不清 +- 主题八(第十一节):实现与接口定义不符 +- 主题九(第十二节):对称性缺口 +- 主题十(第十三节):两个公共模块的边界 +- 第十四节:**被推翻或收窄的说法**(避免把好设计改坏,动手前值得看一眼) +- 第十六节:**明确不建议动的部分**(防止范围蔓延) +- 附录 A:全部 172 条结论清单 +- 附录 C:与另一份评审文档的交叉核对 +- 附录 D:完备性补检(含两条最有行动价值的发现) + +--- + +## 二、任务清单(按优先级) + +状态图例:`待做` | `进行中` | `已完成` | `待拍板` + +### 第一优先级 · 正确性缺陷 + +**已全部完成(2026-07-25,6 个独立提交)。** 动手前按符号在 HEAD 上逐条复核,六条缺陷全部属实;对任务文档的两处订正见 HANDOFF。 + +这一批不涉及接口设计取舍。**六条的可观察性并不相同**(这是写任务文档时逐条核实后收窄的结论): + +| 编号 | 现在会发生什么 | 归责 | +|---|---|---| +| 01 | **当前生效**:三路以上的 OR 谓词会静默少行 | **本次迁移引入的回退** | +| 02 | **默认潜伏**:优化器有一条改写规则在过滤条件位置会先把 `列 <=> 值` 改成 `列 = 值`,通常到不了连接器的坏分支;关掉那条规则才会走到,且一旦走到就静默少行 | 上游既有缺陷的移植 | +| 03 | **推断成立但未经端到端确认**:含 `_`、转义符或中间 `%` 的 LIKE 被收窄成前缀匹配 → 少行 | 上游既有缺陷的移植 | +| 04 | **当前生效**:复杂类型的字段名变成 `col0` / `col1` | **本次迁移引入的回退** | +| 05 | **当前生效但条件有限**:仅分区 hudi 表、且会话缓存开关打开时,查询缓存永久不启用 | 迁移期引入 | +| 06 | **潜伏**:两个连接器今天都还没有调用那个方法,所以还看不到错误;但机理会让以后每加一个方法都再漏一次类加载器钉桩 | 迁移期引入 | + +「潜伏」不等于可以不修——连接器的正确性不能依赖一条可以被关掉的优化规则。但它决定了**验证方式**:02 号必须先关掉那条改写规则才压得到连接器路径,03 号必须先写出能复现的用例并看到它变红。 + +| 编号 | 任务 | 风险 | 依赖 | 状态 | +|---|---|---|---|---| +| 01 | [修复 trino 对三路以上 OR 谓词只取前两支导致的丢行](./tasks/01-fix-trino-or-predicate-row-loss.md) | 低 | 无 | **已完成** | +| 02 | [修复 paimon 把「空值安全等于」下推成 IS NULL 导致的丢行](./tasks/02-fix-paimon-null-safe-equal-pushdown.md) | 低 | 无 | **已完成** | +| 03 | [修复 paimon 把含通配符或转义的 LIKE 收窄成前缀匹配](./tasks/03-fix-paimon-like-prefix-narrowing.md) | 低 | 无 | **已完成**(e2e 已写,待有集群执行) | +| 04 | [修复 trino 丢弃复杂类型字段名导致引擎编造 col0 / col1](./tasks/04-fix-trino-struct-field-names-lost.md) | 低 | 无 | **已完成**(含构造期校验) | +| 05 | [修复 hudi 在「最后修改时间」字段里填时刻串导致查询缓存永久失效](./tasks/05-fix-hudi-partition-timestamp-unit.md) | 低 | 无 | **已完成**(e2e 已写,待有集群执行) | +| 06 | [补齐两个连接器对引擎上下文的转发缺口并根治其机理](./tasks/06-fix-engine-context-forwarding-gap.md) | 低 | 无 | **已完成** | + +### 第二优先级 · 零风险,让接口可读可依赖 + +不改变任何运行时行为,但直接解决「新增连接器时能照着接口定义清晰实现」这个目标——今天一个新连接器作者的主要困难不是接口能力不够,而是**没有任何地方告诉他规则是什么,而文档告诉他的有一部分是错的**。 + +| 编号 | 任务 | 风险 | 依赖 | 状态 | +|---|---|---|---|---| +| 07 | [把公共模块的设计规则写下来(两个模块各一份包级说明)](./tasks/07-write-down-the-design-rules.md) | 无 | 无 | **已完成** | +| 08 | [修正与实现矛盾的接口文档(一批)](./tasks/08-fix-stale-interface-docs.md) | 无 | 无 | **已完成**(默认值不动,只改文档) | +| 09 | [补全下推表达式的契约(逐算子语义 + 不可精确翻译必须放弃下推)](./tasks/09-complete-pushdown-expression-contract.md) | 无 | 01/02/03 的根因 | **已完成**(纯文档,不加工具方法) | +| 10 | [把 ConnectorTableOps 按域拆成父接口,并为每域写清最少实现集](./tasks/10-split-table-ops-by-domain.md) | 无(已核实零破坏) | 07 | **已完成** | + +### 第三优先级 · 删除死接口面 + +死接口的成本不是占空间,是**逼着每个新连接器为不存在的出口交税**,并持续制造「我实现了为什么没生效」的排查浪费。 + +| 编号 | 任务 | 风险 | 依赖 | 状态 | +|---|---|---|---|---| +| 11 | [删除第一批死接口面(公共模块内部,无连接器改动)](./tasks/11-delete-dead-surface-batch-one.md) | 低 | 无 | **已完成**(5 个提交;含 24 号的删除决定) | +| 12 | [删除第二批死接口面(需连带修改连接器)](./tasks/12-delete-dead-surface-batch-two.md) | 中 | 11 | **已完成**(含主键删除拍板 + 冻结基线 80→75) | +| 13 | [删除分片类型枚举族(本轮最有价值的一条删除)](./tasks/13-delete-scan-range-type-enum.md) | 中 | 11 | **已完成**(jdbc 少发一个 BE 不读的键,配新单测) | +| 14 | [删除推模型的缓存失效接口,让「失效」只剩一个方向](./tasks/14-delete-push-model-cache-invalidation.md) | 中 | 06(同改两个包装类) | **已完成**(删除点已收到公共转发基类) | + +### 第四优先级 · 兑现「新增连接器不改公共模块」 + +| 编号 | 任务 | 风险 | 依赖 | 状态 | +|---|---|---|---|---| +| 15 | [删除目录类型白名单,让注册了插件的连接器真的能被路由到](./tasks/15-remove-catalog-type-allowlist.md) | 中 | 无 | **已完成**(2 个提交;顺带修掉重放期让 FE 退出的缺陷) | +| 16 | [把引擎里三处按数据源名判定的软阻塞分支改成中立声明](./tasks/16-replace-source-name-branches.md) | 中 | 15(同类形态) | **已完成**(3 个提交;四处全做,剖析两行 N/A 已拍板删除) | +| 17 | [把按表能力从字符串升级为类型化集合,并删掉写特性的镜像方法](./tasks/17-typed-per-table-capabilities.md) | 中 | 07 | **已完成**(4 个提交;引擎读取范围**未**扩大,见 HANDOFF) | +| 18 | [把建表能力从引擎白名单下沉为连接器声明](./tasks/18-push-down-create-table-capability.md) | **高** | 15 | **已完成,但不是按该文档做的**——owner 拍板升级为「fe-core 不再用 engine 做判定,改按 catalog 路由」,分四步,**四步已全部落地**(8 个提交)。该文档的核心机制(连接器实例声明 + 「字段为空才初始化」)已证伪,见 HANDOFF | + +> ~~**15 号是这条工作线的核心。**~~ **已完成(2026-07-25)。** 「新增连接器不需要修改公共模块」这句话在 `CREATE CATALOG` 这条路径上第一次成立:装上插件就能建目录,不用改任何公共模块。仍有几处**特性级**的按源名判定没动(建表能力、文件缓存准入、引擎展示名),分别是 16 / 17 / 18 号。 + +### 第五优先级 · 中立化与结构整理 + +| 编号 | 任务 | 风险 | 依赖 | 状态 | +|---|---|---|---|---| +| 19 | [把 Elasticsearch 专有的逃生门与通用扫描节点里的 ES 分支归位](./tasks/19-relocate-elasticsearch-specific-surface.md) | 中 | 21(需要中立合成键) | **已完成**(2 个提交;文档的核心机制已作废,按 21 号建立的共享常量重做,且只加 2 个键不是 3 个) | +| 20 | [分区空值哨兵的中立化命名与归一方法下沉](./tasks/20-neutralize-null-partition-sentinel.md) | 中 | 无 | **已完成**(2 个提交;不留过时别名;新测试先在旧实现上跑绿) | +| 21 | [把扫描节点属性表的键契约集中到公共模块](./tasks/21-centralize-scan-node-property-keys.md) | 低 | 无 | **已完成**(1 个提交;两半改动互相交织,未按文档拆两个) | +| 22 | [把分布式过程的结果列定义交还连接器](./tasks/22-return-procedure-result-schema-to-connector.md) | 中 | 无 | **已完成**(1 个提交;列名/类型/注释逐字搬迁,含两处刻意保留的历史 quirk) | +| 23 | [把引擎上下文里的存储服务收成独立服务对象](./tasks/23-split-connector-context-storage-services.md) | ~~**高**~~ 中 | 06 | **已完成**(2026-07-26,2 个提交)。该文档的「高危」评级已过期:它写于 06 号合入前,今天两个钉桩包装类只覆写 `executeAuthenticated`,**本次零改动**。**插件包重部署冒烟未跑**(本地无集群),见 HANDOFF | + +### 调研期发现、已修复的真实缺口 + +这两条不是接口设计问题,是调研过程中撞出来的用户可见缺陷,都在「一个 HMS 目录同时服务两种表格式」的委派路径上。已随第一批一起修掉。 + +| 编号 | 任务 | 风险 | 状态 | +|---|---|---|---| +| 26 | 异构目录下嵌套列 DDL 未被委派给兄弟连接器(同一张表在独立目录里可以做、在异构目录里报「不支持」) | 低 | **已完成**(e2e 已写,待有集群执行) | +| 27 | 异构目录下 iceberg 表的注释显示为空(取注释的方法签名里没有表句柄,网关按句柄类型路由的手法用不上) | 低 | **已完成**(e2e 已写,待有集群执行) | + +> 27 号还留下一条结构性结论,后续任何「按句柄委派」的设计都要考虑:**不带表句柄的方法无法被网关委派**。已核查其余无句柄方法——取主键在 fe-core 侧无生产调用方、列视图名由 hive 统一枚举、直通类只有 jdbc 实现、构造表描述符两侧字节相同(但这条只是碰巧安全,依赖网关强制兄弟走 hms 目录类型)。 + +### 待拍板与收尾 + +| 编号 | 任务 | 说明 | 状态 | +|---|---|---|---| +| 24 | [连接器自声明属性:接线还是删除](./tasks/24-decision-connector-declared-properties.md) | **决策文档**。牵动 FE 配置项的用户可见位置,必须由人拍板,不宜由整治批次顺手带过 | **已拍板:删**(选项二,已随 11 号落地;包级说明补了规则七) | +| 25 | [修正同日另一份评审文档里的事实错误与结论](./tasks/25-fix-earlier-review-doc-errors.md) | 勘误清单,5 处事实错误 + 3 处结论 | **已完成**(3 个提交;勘误本身已过期,处置方案改为"状态戳 + 重编两块",见该文档第九节) | + +### 复核登记的开放项(2026-07-26,只登记不排期) + +做 25 号时对那份 689 行评审文档做了全文复核,登记了下面这些当时仍然成立、且没有任何任务认领的条目。**十条已全部完成**(2026-07-26 至 07-27,第十三、十四批): + +| 项 | 内容 | 状态 | +|---|---|---| +| ~~异常契约矛盾~~ | ~~统计接口"列举文件大小"的 javadoc 要求吞异常返回空集合~~ | **已完成**(2026-07-26,四票对一票,javadoc 一句话,零行为变化) | +| ~~死接口幸存者~~ | ~~`estimateScanRangeCount`:零引擎调用、唯一实现在 jdbc~~ | **已完成**(第十三批,删除,全仓复扫归零) | +| ~~读事务生命周期矛盾~~ | ~~接口声称扫描计划提供者"每次新建、无状态",却要求它释放另一次调用中开启的事务~~ | **已完成**(第十三批,契约改为"跨调用状态归连接器、按 query id 存",即 hive 的既有做法) | +| ~~SQL 直通仍在共享接口上~~ | ~~`executeStmt` / `getColumnsFromQuery` 是 `ConnectorTableOps` 仅剩的两个自有方法~~ | **已完成**(第十三批,独立成 `ConnectorPassthroughSqlOps`,并删掉与之重影的能力位) | +| ~~建库布尔位手工同步~~ | ~~`supportsCreateDatabase()` 与 `createDatabase()` 靠实现者手工保持一致~~ | **已完成**(第十三批,删布尔位、预检改无条件,对齐 Trino;有一处用户可见变化) | +| ~~两个属性命名空间静默合并~~ | ~~`ConnectorSession.getProperty` 先查会话变量再查 catalog 属性,同名静默覆盖~~ | **已完成**(第十三批,写清优先级并指出要区分来源该读哪两个方法) | +| ~~`planScan` 4 重载~~ | ~~"一个方法 + 一个请求对象"~~ | **已完成**(第十四批,1 个提交):四个重载收成一个抽象方法 + `ConnectorScanRequest`,14 个连接器覆写塌成 8 个 | +| ~~`getWriteContext()` 名不符实~~ | ~~引擎侧已经在用建议的新名字,两个名字跨边界并存~~ | **已完成**(第十三批,改名 `getStaticPartitionSpec()`) | +| ~~`getLength()` 单位未澄清~~ | ~~文档仍只写"字节数",各连接器语义分歧原样~~ | **已完成**(第十三批,写明单位由连接器定义、引擎不得当字节读) | +| ~~契约校验器缺 hive 正样本~~ | ~~唯一声明"分区哈希写"的 hive 没有调用点~~ | **已完成**(第十三批,新增 hive 契约测试,每条不变量都有真连接器正样本) | + +--- + +## 三、建议的执行顺序 + +1. ~~**07 + 08 + 10**(写规则 + 文档据实 + 零破坏分域)~~ **已完成**。 +2. ~~**01~06**(六个正确性缺陷)~~ **已完成**(2026-07-25,6 个提交)。 +3. ~~**11**(第一批死接口面删除)~~ **已完成**(10 项,5 个提交)。 +4. ~~**15**(删类型白名单)~~ **已完成**——这一步之后,「新增连接器不改公共模块」在建目录路径上第一次成立。 +5. ~~**09 + 12 + 13 + 14**(下推契约 + 连带改连接器的删除)~~ **已完成**(2026-07-25,4 个提交)。 +6. ~~**21 + 16**(属性键集中 + 按源名分支下沉)~~ **已完成**(2026-07-26,4 个提交)。 +6b. ~~**17**(能力载体类型化 + 删写特性镜像)~~ **已完成**(2026-07-26,4 个提交)。 +7. ~~**19 + 20 + 22**(中立化)~~ **已完成**(2026-07-26,5 个提交)。 +8. ~~**25**(勘误清单,纯文档)~~ **已完成**(2026-07-26,3 个提交)。实际做出来的**不是**原计划的"就地改 8 处"——重侦察发现那份文档写于 44 个提交之前、约 26 个论断已失效,勘误清单自己也过期了,改为"保留分析正文 + 状态戳/交叉核对修正/就地重算 + 重编两张表与行动清单",并把那份从未入库的文档纳入版本控制。 +9. ~~**18**(建表能力下沉)~~ **已完成**(2026-07-26,5 个提交),但做出来的不是原文档的方案:owner 拍板改为「fe-core 不再用 engine 做判定,按 catalog 路由」,分四步——①删已死的 MODIFY ENGINE 子系统 ②目录级判定入口 + 连接器声明引擎名 ③CreateTableInfo 与 InternalCatalog 去引擎化 ④展示串下沉。**第 4 步未做**,前置问题见 HANDOFF。 +10. ~~**第 4 步**(展示串下沉:`PluginDrivenExternalTable` 的两个 switch 交给连接器)~~ **已完成**(2026-07-26,3 个提交)。owner 拍板改变现状而非兼容保留:两个展示位置显示同一个名字,由 `ConnectorProvider.displayEngineName()` 决定(默认取目录类型名,只有 MaxCompute 覆写)。**这一步之后 fe-core 里没有任何一处按数据源名的分支了。** 上一轮列的三个前置问题,侦察后两个不成立(假 provider 隔离仓库早有做法;远端 Doris 基线根本不受影响),真问题只有热循环里 `getProperties()` 的整表复制,解法是在目录侧解析一次并记住。 +11. ~~**23**(上下文拆分)~~ **已完成**(2026-07-26,2 个提交)。**25 个编号任务至此全部完成。** +12. ~~**开放项 9 条中的 8 条**~~ **已完成**(2026-07-26,第十三批 8 个提交)。 +13. ~~**`planScan` 重载合并**~~ **已完成**(2026-07-27,第十四批 1 个提交)。**「复核登记的开放项」至此全部清空**;剩下的只有积压的端到端与插件包重部署冒烟(都需要集群)。 + +前三步全部不改变运行时行为,端到端测试只作兜底;从第 5 步起端到端是必需项而非兜底。 + +--- + +## 四、落地纪律 + +- **每个任务一个独立 commit**;任务文档与代码分开提交。提交信息用英文,标题形如 `[refactor](catalog) fe-connector-api: …`。 +- **统一验收口径**:全反应堆**含测试源**的 `test-compile` 通过(这是最强的单一符号级信号,禁用跳过测试编译的参数)→ 受影响连接器的单测与契约测试 → 该批可能改变行为的端到端子集 → 对被删符号复扫应为零命中。 +- **删除类任务额外要求**:对被删符号在全仓库(含 `fe-core`、8 个连接器、测试、`be-java-extensions`、服务发现配置、反射字符串)复扫,命中数必须为 0。 +- **不要写 shell 或正则的静态门禁**去校验语言语义。本仓库已有结论:那类门禁只适合存在性与前缀类不变量,要理解布尔逻辑/多行/字符串/三元式极性就等于在 shell 里写 Java 解析器,误报比漏报更毒(会挡住合法构建)。需要机器验证不变量时,优先「运行时行为单测 + 代码注释 + 评审」。 +- **改动方向纪律**:`fe-core` 只出不进——不为解决问题往 `fe-core` 新增数据源相关代码;遇到「删 A 才能编译过」就把逻辑就近挪进 `fe-core` 工具类的冲动时,停手重新分析真实归属。 + +--- + +## 五、需要拍板的事 + +完整清单见 **[open-decisions.md](./open-decisions.md)**(约 40 条,多数有推荐值照做即可)。**必须先定的四件事**(其中两件已拍板): + +1. ~~**含隐式类型转换的谓词下推默认值**(08 号)~~ **已拍板并落地**(2026-07-26,第十三批):保持默认 `true`,但让每个连接器显式写出自己的答案。侦察发现它并非五家齐一——iceberg / es / trino 真的会拿它做源端过滤(三家就地声明 `true` 并写明这是「接受风险」而非「安全声明」),hive / hudi 对残余谓词根本不消费、这个开关对它们是死的(因此不加空覆写,改在 SPI 文档里记下整张地图)。零行为变化。 +2. ~~**连接器自声明属性:接线还是删除**(24 号)~~ **已拍板:删除**(2026-07-25),已随 11 号落地,入口形状写进了包级说明的规则七。 +3. **建表能力下沉的报错文案变化**(18 号):三类组合的拒绝点会提前、文案会变。同一份文档还纠正了一个会误导施工的事实——**仓库里根本不存在分桶子句的正向端到端护栏**,这个高风险任务必须自己补一条。 +4. ~~**插件与内建目录类型的路由优先级**(15 号)~~ **已拍板并落地**:插件优先(现有类型行为不变)+ **三个内建类型名成为保留字,插件声明它们时在注册期就被拒绝**。借鉴 Trino 的「单一注册表 + 重名直接拒绝」,于是「遮蔽」不再是需要接受的代价。 + +11 号里的「是否外部表」字段**已拍板:删除**(2026-07-25)。判断依据见那份任务文档的落地记录第 8 条:它在任何能到达连接器的路径上都是编译期常量 `true`,而「保留并让 hive 真正消费」按字面做会把所有 Doris 建的 hive 表从托管表改成外部表、`DROP TABLE` 不再删数据。读侧的主键方法**已拍板:删除**(2026-07-25,随 12 号落地)——两条通道都没有读取方,其中保留属性键那条是「写进去就被剥掉」。 diff --git a/plan-doc/connector-public-interface-cleanup/audit-report.md b/plan-doc/connector-public-interface-cleanup/audit-report.md new file mode 100644 index 00000000000000..3d9e4011e369ca --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/audit-report.md @@ -0,0 +1,1632 @@ +# 连接器公共接口设计调研(fe-connector-api / fe-connector-spi) + +> **你在哪里**:这份文档是 `plan-doc/connector-public-interface-cleanup/` 任务空间的调研报告底稿。 +> **要动手做事请先看** [README.md](./README.md)(任务清单)与 [HANDOFF.md](./HANDOFF.md)(起步必读)。 +> 本文很长(1600 余行),**不建议通读**——按 README 里的章节导航 grep 定位到需要的那一段。 + +> 调研日期:2026-07-25 基线:分支 `catalog-spi-review-19`,HEAD `7ff51a106f0` +> 调研范围:`fe/fe-connector/fe-connector-api`(95 个源文件)与 `fe/fe-connector/fe-connector-spi`(5 个源文件),以及它们在 `fe/fe-core` 的调用点和在 **8 个连接器**(es / hive / hudi / iceberg / jdbc / maxcompute / paimon / trino)里的实现。搜索范围还覆盖了 `fe-connector-hms` 等共享模块,但那些不是连接器(`fe-connector-hms` 是内联的元存储客户端副本,hms 网关由 hive 连接器承载)。 +> 目的:让公共接口的设计规范化,使得**以后新增一个连接器时,能够照着接口定义清晰地实现,并且不需要修改 `fe-core`、`fe-connector-api`、`fe-connector-spi` 这三个公共模块**。 + +--- + +## 一、这份文档是什么,以及怎么读它 + +### 1.1 调研方式 + +这一轮是**独立重做**的调研,不是对已有结论的复述。做法分三步: + +1. **独立并行审查**:14 个互不可见的审查单元同时开工——9 个按接口分区(连接器入口与能力模型 / 表与库元数据 / 写入与事务 / 扫描与分片 / 下推 / 多版本与统计 / 存储过程与建表请求 / 会话与装配层 / 校验与事件),5 个按横切维度(可扩展性卡点穷举、源专有语义扫描、死接口面普查、契约一致性核对、重复机制排查)。每个单元都被明确要求**只看代码**,不得阅读 `plan-doc/` 下任何已有评审文档,且每条结论必须给出在当前代码上实测到的 `文件:行号` 与原文摘录。 +2. **对抗式复核**:把去重后的每一条结论交给复核者,复核者的默认立场是「这条是错的」,只有在代码上亲自验证且推翻失败时才判为成立。复核者要重跑「无调用方 / 无实现方」类断言的全仓搜索,并检查建议是否撞上客观障碍(例如 `fe-core` 只出不进的隔离纪律、Gson 持久化兼容、跨插件类加载器边界、FE 与 BE 的 thrift 有线格式兼容)。 +3. **我本人的独立通读**:在上述结论产出之前,我自己完整读过 `Connector`、`ConnectorMetadata`、`ConnectorType`、`ConnectorCapability`、`ConnectorSession`、`ConnectorProvider`、`ConnectorContext` 全文,以及六个 `Ops` 子接口和两个 Provider 的全部方法签名,并亲手验证了若干条最容易误判的结论(死接口面、契约校验器是否真的在跑)。文档里的判断以这一层为准,不是转述。 +4. **交叉核对与完备性补检**:一个单元持有两轮结论并逐条回到代码实测,产出附录 C;另一个单元反过来问「这轮漏了什么」——把 100 个公共源文件与已确认结论做交叉、逐个读完 19 个完全未被覆盖的文件、并把「由引擎实现、连接器消费」的那几个接口当作一类单独审,产出附录 D。**附录 D 里有本次调研最有行动价值的一条**(引擎实现的接口默认值全是静默降级,且两个类加载器钉桩包装类今天已经漏转发),以及第二、第三个可能静默少行的连接器缺陷。 + +### 1.2 结论规模与可信度口径 + +原始结论 194 条,跨单元去重后 176 条,经对抗复核: + +| 判定 | 条数 | 含义 | +|---|---|---| +| 成立 | 59 | 现象与原因都经代码验证,确实违反公共接口设计原则 | +| 部分成立 | 113 | 现象存在,但原始描述过宽、定位不准或原因分析有误;复核给出了更准确的表述 | +| 被推翻 | 4 | 事实错误,或有充分设计理由、不应算缺陷 | + +附录 D 的补检结论**不在这 172 条里**(它们是针对本轮盲区新增的),其中包含 1 条高、1 条中高与若干条低。 + +「部分成立」占比高(三分之二)本身是一个重要信息:**这套接口的问题大多不是「某个方法写错了」,而是「文档说的和代码做的不是一回事」**——所以第一遍看代码的人容易把现象描述得比事实更严重。文档正文采用复核后的准确表述;附录 A 给出全部 172 条成立/部分成立结论的完整清单,逐条带位置,可独立复核。 + +**行号可能随后续提交漂移。核对时请以符号名为准,不要以行号为准。** + +### 1.3 与同一天另一份文档的关系 + +`plan-doc/connector-api-spi-design-review-2026-07-25.md` 是同一天早些时候另一轮审查的产物(689 行)。本文是**独立重做**的一轮,写作时正文结论完全不依赖那份文档;两者的交叉核对结论见附录 C。保留两份而不是覆盖,是因为两轮独立结论的一致部分本身就是最强的可信度证据,而分歧部分正是最需要人来拍板的地方。 + +### 1.4 一句话结论 + +**这套接口的抽象层次和默认值设计是对的(全默认方法 + 能力 opt-in,与 Trino 的路线一致),真正的问题集中在五件事上:** + +1. **引擎侧还留着一层按数据源类型名硬编码的开关**,使得「注册了插件就能工作」这个承诺目前不成立——最关键的一处是 `fe-core` 里的类型白名单。 +2. **「声明一项能力」有多条并行通道**,其中一条是把能力名拼成逗号分隔字符串塞进属性表,拼错不会报错、只会静默失效。 +3. **接口文档与实现大面积脱节**:有的方法文档承诺的用途和真实用途完全不同,有的契约(异常、单位、null 语义)被实现有意违背,有的扩展点从来没有被引擎调用过。新连接器作者按文档写,会写错。 +4. **有相当规模的死接口面**:零调用方或零实现方的方法/类/枚举。它们的成本不是占空间,而是**逼着每个新连接器为不存在的出口交税**,并持续制造「我实现了为什么没生效」的排查浪费。 +5. **由引擎实现的那几个接口(上下文、会话)几乎全是带默认实现的方法,而默认值都是「静默降级」**。它既让引擎可以合法地不履约,也让连接器侧的逐方法转发包装类漏一个方法都不会报错——**今天已经漏了**(附录 D.2)。这一类接口此前没有被任何审查视角覆盖过。 + +--- + +## 二、判断标准 + +调研用的是下面八条公共接口(API/SPI)设计原则。它们不是凭空拟的,前四条对应用户提出的四类问题,后四条是审查过程中必须一并核对的配套要求。 + +1. **中立性**:这两个模块被所有连接器共享,不应出现只有某一个数据源才有的概念、命名、常量或语义假设。要特别警惕第二种形态——**名字是通用的,但语义只对某一个源成立**,这比直接写 `hive` 更危险,因为它不会被任何按名字扫描的检查发现。 +2. **可扩展性**:新增一个连接器不应需要修改 `fe-core`、`fe-connector-api`、`fe-connector-spi`。封闭枚举、类型白名单、按类型的 `switch` 与 `if` 链、对公共类型的 `instanceof` 链、硬编码注册表,都违背这一点。 +3. **无冗余**:不应存在没有生产调用方或没有实现方的接口;同一件事不应有多套并存的表达;不应有无意义的重载堆叠或几乎重复的值对象。 +4. **语义清晰**:方法名要与实际行为一致;返回值的单位、`null` 与空集合的区别、异常契约、线程安全、幂等性要写清;不应用 `Map` 偷运结构化信息而不定义键的契约。 +5. **单一职责**:一个接口不应把互不相干的能力捆在一起,逼迫连接器实现与自己无关的方法。 +6. **实现一致性**:接口文档声明的契约,各连接器的实现要真的遵守。 +7. **对称性**:读与写、表级与连接器级、单条与批量等成对能力不应有缺口。 +8. **抽象不泄漏**:公共接口不应暴露引擎内部类型,也不应要求连接器依赖引擎内部实现细节。 + +**关于第 2 条有一个前提必须先说清,否则后面每一条建议都会被它误伤:**「新增连接器不应需要修改公共模块」禁止的是「**每**新增一个连接器都要**再**改一次」,不是「永远不许往公共模块加声明位」。给 `ConnectorProvider` 加一个带默认实现的方法,是一次性的开放动作——加完之后第 9 个、第 10 个连接器都不必再碰它。本文所有「新增声明位」的建议都是这个性质;凡是做不到一次性的(例如 BE 侧按数据源开的 thrift 槽位),一律归入「结构性约束,不建议动」。 + +--- + +## 三、总体判断 + +### 3.1 先说做得对的部分 + +为了让后面的批评有参照,先明确这套接口在架构上做对了什么——这些**不应该**在整治中被改回去: + +- **全默认方法 + 能力 opt-in**。`ConnectorTableOps` 的 46 个方法全部有默认实现,`ConnectorScanPlanProvider` 24 个里 23 个有默认实现。新连接器只实现自己支持的部分,其余自动降级。这与 Trino 的 `ConnectorMetadata`(上百个默认方法)是同一条路线,是正确的。 +- **异构网关的按表 provider 选择**。`Connector.getScanPlanProvider(handle)` / `getWritePlanProvider(handle)` / `getProcedureOps(handle)` 让一个目录服务多种表格式(Hive 元存储目录里的 Iceberg 表委派给 Iceberg 连接器)。Trino 没有这个模型(它靠多个目录解决),这是 Doris 特有且必要的本地化。 +- **按语句作用域的解析记忆(`ConnectorStatementScope` / `ConnectorStatementScopes`)**。键的约定(命名空间以连接器类型名为前缀,因此跨连接器不撞由构造保证)写得清楚,且刚刚完成了把源专属命名空间常量下沉到各连接器、公共模块只留中立约定的整理。这是本轮唯一一处「接口设计做得完全干净」的地方,可以作为其它部分的范本。 +- **存储与凭证的归属**。属性解析放在插件侧、`fe-core` 不解析元存储属性,这条既定架构目标在 `ConnectorContext` 上被贯彻得比较彻底。 + +### 3.2 五个结构性问题(一句话版) + +| # | 问题 | 最关键的一处证据 | +|---|---|---| +| 一 | 引擎侧的类型白名单让「装了插件就能用」不成立 | `fe-core` 的 `CatalogFactory.java:56` 用一个写死的七元素集合决定谁能走插件路径 | +| 二 | 「声明一项能力」有多条并行通道,其中一条无编译期约束 | `ConnectorTableSchema.java:98` 把能力名拼成逗号分隔字符串塞进属性表,且只对 13 个能力中的 5 个生效 | +| 三 | 入口接口与元数据接口把互不相干的职责捆在一起 | `Connector` 34 个方法里有 11 个只是把写计划提供者的同名方法再镜像一遍 | +| 四 | 相当规模的死接口面,且有的还是**强制实现**的 | `ConnectorScanRange.getRangeType()` 是 8 个连接器必须实现的抽象方法,它的返回值全仓库无人读取 | +| 五 | 接口文档与实现脱节,新人照文档写会写错 | `ConnectorTableOps.listPartitionValues` 的文档说它服务某个表函数,而那个表函数走的是另一个方法;这个方法零生产调用方,却有三个连接器实现了它并配了单测 | +| 六 | **由引擎实现的那几个接口,默认值全是「静默降级」,而且今天已经有两个包装类漏转发** | `ConnectorContext` 19 个方法里 17 个有默认实现,iceberg 与 paimon 的类加载器钉桩包装类各漏转发 1~2 个方法,漏转发不报编译错、只会静默返回接口默认值(`getFileSystem` 默认返回 `null`)。详见附录 D.2 | + +--- + +## 四、主题一:新增一个连接器,今天必须碰公共模块的哪些地方 + +这是本次调研最核心的一节。26 项相关结论按「一个仓库外的第三方连接器作者今天会撞上什么」分层,因为混在一起看会导致投入错配。 + +### 4.1 硬阻塞:不改公共模块,连接器根本跑不起来,或该能力完全不可用 + +#### (1)目录类型白名单——最该删的一处 + +`fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java:56` 有一个写死的集合: + +```java +private static final Set SPI_READY_TYPES = + ImmutableSet.of("jdbc", "es", "trino-connector", "max_compute", "paimon", "iceberg", "hms"); +``` + +判定点在同文件 `:110` 与 `:119`:**只有类型名落在这个集合里,引擎才会去问插件**。一个第三方连接器即使正确注册了 `ConnectorProvider`、被插件加载器成功装配,`CREATE CATALOG` 也不会走到它——会掉进后面的内建类型 `switch`,最终抛「未知目录类型」。 + +这个集合目前承担两件事,必须拆开看: + +- **迁移期灰度门禁**:只让这七个类型走插件路径。这个作用可以直接删。 +- **排除 hudi**:hudi 已经注册了 `ConnectorProvider`(返回类型名 `"hudi"`),但它没有独立的目录概念——一张 hudi 表寄生在 Hive 元存储目录上,运行时由 hms 网关通过 `ConnectorContext.createSiblingConnector("hudi", ...)` 构造为内嵌兄弟连接器。代码注释里明确写了「不要把 hudi 加进这个集合」。这个作用必须有替代品。 + +**建议**:把第二件事上移为 provider 的显式声明,然后删掉白名单。 + +```java +// fe-connector-spi: ConnectorProvider +/** + * 本 provider 的类型能否作为一个独立目录出现在 CREATE CATALOG 的 type 属性里。 + * 返回 false 表示这个连接器只以内嵌兄弟身份存在(由另一个连接器构造并持有), + * 引擎不会为它建独立目录;它仍然参与服务发现与兄弟查找。默认 true。 + */ +default boolean isStandaloneCatalogType() { + return true; +} +``` + +目录创建流程改为:**先问插件(不带任何白名单)→ 再问引擎内建类型 → 都没命中才报错**。这个形状比现状严格更好:现在如果元数据镜像里存在一个不在白名单里的外部目录,重放(replay)会直接抛异常导致 **FE 起不来**;改后一律降级注册,首次访问时报错。 + +顺带说明一个副作用:白名单存在,使 `ConnectorProvider.supports(catalogType, properties)`(按属性而非仅按类型名分派的能力)**实际不可用**——目前 0 个连接器覆写它。删掉白名单后这个能力才真正激活。 + +#### (2)`CREATE TABLE` 的四处协同改动 + +新连接器要支持建表,今天必须在 `fe/fe-core/.../nereids/trees/plans/commands/info/CreateTableInfo.java` 改四个地方:目录类型到引擎名的 `switch`(`:918-926`)、引擎名白名单(`:954-976`)、分区与分桶子句的允许列表(`:1135-1145`),另有一处引擎名常量。代码注释 `:911` 自己写着「两个 switch 必须保持同步」——这是一个已知的、被记录下来的重复。 + +**建议**:把「本目录建表用哪个引擎名」下沉为连接器声明(返回空表示不支持建表),把「允许 PARTITION BY / DISTRIBUTED BY 子句」做成两个能力位。引擎侧的三处硬编码随之退化为读声明。这一批风险最高,必须靠端到端测试兜住——尤其要保持**校验的优先级顺序**,此前已有过把连接器内多阶段校验合并后丢掉优先级、导致建表用例失败的先例。 + +#### (3)扫描分片的格式路由键,中立默认值在 BE 侧没有分支 + +`ConnectorScanRange.getTableFormatType()`(`scan/ConnectorScanRange.java:113-122`)名义上是一个中立字符串,默认值是 `"plugin_driven"`。但 BE 侧对这个值是**白名单式**消费:`file_scanner_v2.cpp` 的两个判定函数各自维护一个已识别集合,不在集合里的值会被 V2 拒收、回落 V1,而 V1 的 reader 链没有无条件兜底分支,最终在 `file_scanner.cpp:1323-1329` 硬失败("Not supported create reader for table format")。也就是说**中立默认值在生产上是不可达的死值**:8 个连接器全部覆写了它。 + +**补检后这一条比上面写的更严重**(见附录 D.1):BE 侧那条本该充当「插件通用逃生门」的 JNI 分支**自己也是封闭的**——`be/src/exec/scan/file_scanner.cpp:1081-1174` 的 JNI 分支内部是 6 段硬编码数据源名的 if-else,**既没有 else,也没有「按参数给我一个 JNI 读取器类名」的通用入口**;原生读取链同样是按源名的 if-else。所以一个新连接器要跑通**读路径**,今天必须改 `gensrc/thrift`(给共享 IDL 加按源的字段)+ `be/src/exec/scan/file_scanner.cpp` + 新读取器类(走 JNI 的还要加 `be/java-extensions` 模块并挂到共享类路径)。**这是本次调研发现的唯一真正的硬阻塞**:它不在 FE 侧,改不掉。 + +**建议**:不新增机制,改两件事——把方法从「带默认值」改成**抽象方法**(让「新连接器必须显式做这个决定」在编译期就显形),并在文档里写清「这不是自由字符串,取值必须属于 BE 已识别的集合,返回未识别值会在查询期硬失败,新增取值必须同步改 BE」,同时说明这是「BE 是 C++ 且无插件机制」的结构性后果,不是本接口能消除的。彻底修法(在 BE 的 JNI 分支加一个通用兜底,读取器类名与参数由属性表承载)投入产出不成立——树外连接器如果要复用某个已有读取器,直接返回那个读取器的名字就行。 + +### 4.2 软阻塞:连接器能跑,但拿不到与既有数据源同等的行为 + +这一层的共同形态是:`fe-core` 里用数据源**类型名字符串**做判定,新连接器不在名单里就静默拿不到某个行为。四处: + +| 位置 | 现状 | 后果 | 建议 | +|---|---|---|---| +| `MetastoreEventSyncDriver.java:119` | `!"hms".equalsIgnoreCase(type)` 决定是否为一个尚未初始化的目录做一次性强制预初始化以播种事件游标 | 新连接器即使实现了 `Connector.getEventSource()`,只要目录从未被查询过,事件源永远不会被激活。而 `Connector.getEventSource()` 的文档明确承诺「引擎的事件驱动完全与连接器无关,从不用 `instanceof`」——这句话在未初始化目录上是假的 | provider 上加 `providesEventSource()`(默认 false),引擎按它判定 | +| `FileQueryScanNode.CACHEABLE_CATALOGS:119` | 按目录类型白名单决定表是否纳入 BE 文件缓存的准入治理 | 新连接器静默绕过缓存治理 | 做成能力位(「本连接器的数据由 BE 原生文件读取器读取,参与文件缓存」) | +| `Env#changeCatalog:6509` | 硬编码 `"es"` → 切入 `default_db` | 新连接器无法声明「切换到本目录时自动进入某个默认库」 | provider 上加 `defaultDatabaseOnUse()`(默认空) | +| `DefaultConnectorContext#buildEnvironment:568-596` | FE 全局配置项逐键手工转发给连接器(9 个键,其中一处注释明写「键名必须与某连接器里的读取处逐字一致」);`ConnectorSessionBuilder:222-233` 又另塞两个 | 新连接器要加一个可调参数,必须改 `fe-common` 的配置类、`fe-core` 的会话变量类,再在 `fe-core` 加一行手工转发 | 见 4.5 —— 这是一个更大的话题 | + +另外 `ConnectorScanProfile` 名义中立,但分组名必须匹配 `fe-core` 的 `SummaryProfile` 里按数据源硬编码的常量表,新连接器要输出扫描剖析信息就得改 `fe-core`。 + +### 4.3 不阻塞但仍是按类型分支的地方 + +`PluginDrivenExternalTable.getEngine()`(`:1275-1305`)与 `getEngineTableTypeName()`(`:1313-1330`)各有一张七项类型名 `switch`,用于保留迁移前的用户可见展示名。两张表**逐字重复**。 + +这一处**不建议下沉**:默认分支已经回落到通用值,新连接器零改动即可工作(不是阻塞);消费者只做字符串拼接、不做枚举反查,所以它是纯展示。给连接器开一个「自选展示名」的自由度,代价是把兼容表从一处搬到七个连接器(净增代码)并引入误用面。**只建议把两张重复的表合并成一张,并加注释冻结它**(「翻闸前遗留展示名兼容表,新连接器不入表」)。 + +### 4.4 真正改不掉的结构性约束 + +这些不是缺陷,只应把边界写清楚,避免下一轮审查再把它们当问题提一遍: + +- **`fe-connector-api` 依赖 `fe-thrift`**,以及 `gensrc/thrift` 里按数据源开的联合体字段和 BE 侧的 `if` 链。**根因是 Doris 的 BE 是 C++ 且没有插件机制**:任何需要 BE 解释的负载都必须改 IDL 加 BE,中立字段名救不了这一点。Trino 能做到句柄完全不透明,是因为它的 worker 是 JVM,可以加载插件——这是根因差异,不是设计水平差异。 +- **`ConnectorCapability` 是封闭枚举这件事本身**。能力位的定义就是「引擎必须消费的开关」,一个没有引擎消费方的能力位毫无意义,所以新增一种能力必然涉及引擎改动。这不算可扩展性缺陷。真正该改的是它的**按表维度载体**(见主题二)。 +- **引擎名白名单里的内部表与已下线外部表类型**(`olap` / `mysql` / `odbc` / `broker` 等):用户可见的遗留 SQL 语法,不能移除。 +- **构建脚本里的连接器模块名单**(`build.sh` 两处硬编码九个模块名):可以改成目录通配(仓库里 `tools/check-connector-imports.sh` 已有先例),但 Maven 的 `` 必须显式列出,保留。 + +### 4.5 一个尚未排期、需要先拍板的落点:连接器自声明属性 + +`fe-connector-api` 里**已经有** `ConnectorPropertyMetadata` 这个类,以及 `Connector.getTableProperties()`(`:235`)和 `Connector.getSessionProperties()`(`:240`)两个取得器。这是从 Trino 直译过来的机制——在 Trino 里它是活的,直接支撑 `CREATE TABLE ... WITH (...)` 的合法键校验与 `SET SESSION 目录.属性 = 值`,连接器加一个可调参数**完全不碰引擎**。 + +在 Doris 这边,它**零实现零调用**(全仓库 15 处命中全在类自身和两个取得器内部)。实际通道是 4.2 表格最后一行那条手工转发。 + +这意味着一个真实卡点:**今天所有数据源专属旋钮只能落到 `fe-common` 的全局配置与 `fe-core` 的会话变量这条封闭路径上。** 把这两个已有接口接线成 Trino 那样的声明式属性,是「连接器加参数零公共模块改动」的正解,但它会改变 FE 配置项的用户可见位置(全局配置键变成按目录 / 按会话的属性),涉及兼容承诺——**需要单独决策,不宜和其它整治混在一起**。现存的六个数据源专属配置键都是迁移前既有的 `fe.conf` 名字,必须保留兼容。 + +如果决定不做,那么正确处置是**删掉这三个死接口**,并在设计文档里记下「未来若要做连接器自声明属性,这就是入口形状」——而不是让它们在公共接口里继续当装饰。 + +--- + +## 五、主题二:「声明一项能力」有多条并行通道 + +### 5.1 现象 + +一个连接器要告诉引擎「我支持某项可选能力」,今天有这些互不统一的通道: + +1. **能力枚举集合**:`Connector.getCapabilities()`(`:211`)返回 `Set`,13 个常量。 +2. **按表能力的逗号分隔字符串**:`ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY`(`:98`,键名 `__internal.connector.per-table-capabilities`),把能力名拼成 CSV 塞进表属性 `Map`。 +3. **写计划提供者上的布尔特性**,再在 `Connector` 上镜像一层空安全转发:`Connector.java:132/138/144/150/156/162/168/177/183` 共 9 个方法,方法体一律是「取 provider,为空返 false,否则转发」。 +4. **取得器返回 `null` 当能力探针**:`getScanPlanProvider()`(`:66`)、`getWritePlanProvider()`(`:93`)、`getProcedureOps()`(`:192`)、`getEventSource()`(`:347`)。后者的文档原文就写着「这是一个能力探针取得器」。 +5. **返回 `Optional` 表示「不支持」**:`ConnectorMetadata` 上 5 处,`Connector.schemaCacheTtlSecondOverride()`(`:359`)。 +6. **散落在各子接口上的 `supportsXxx()` 默认方法**:`ConnectorSchemaOps:58`、`ConnectorPushdownOps:72`、`ConnectorTableOps:172`、`ConnectorScanPlanProvider:89/250/268`。 +7. **窄标记接口 + 引擎侧 `instanceof`**:`WriteBlockAllocatingConnectorTransaction`、`RewriteCapableTransaction`。 + +经复核,这七条里**只有三条构成真正的冗余**,其余是不同形态的正当表达(值缺失用 `Optional`、子系统缺席用 `null`、能力开关挂在拥有它的子接口上——后者恰恰是应该收敛到的目标形态)。三条真冗余是: + +- **第 1 条与第 2 条是同一批能力的两条异质通道**(类型化集合 vs 字符串 CSV)。 +- **第 3 条的 11 个镜像方法与写计划提供者上的同名方法语义完全重复**(实测行号 `115/126/132/138/144/150/156/162/168/177/183`,其中 9 个返回布尔、2 个返回写操作集合)。 +- **第 4 条与第 5 条在同一个接口内用两种方式表达「不存在」**。 + +### 5.2 为什么是问题 + +**(a)字符串 CSV 通道没有任何编译期约束,拼错静默失效。** 而且这条通道**只对 13 个能力中的 5 个生效**:引擎只在 `PluginDrivenExternalTable.java:302` 的一个私有方法 `hasScanCapability` 里读 CSV,它的调用方只有 5 处;其余 8 个能力只读连接器级集合,完全绕过 CSV(`PluginDrivenExternalTable:337/353/438`、`PluginDrivenExternalCatalog:316/1289`、`PluginDrivenExternalDatabase:60`、`ShowPartitionsCommand:350`、`QueryTableValueFunction:78`、`CreateTableInfo:794`)。但接口文档承诺的是「任何能力都可以按表细化」。而 hive 网关会把兄弟连接器的**全部**能力反射进 CSV(`HiveConnectorMetadata.java:582`),多余的被静默丢弃。 + +一句话:**接口文档承诺 13/13,实现只兑现 5/13,而且没有任何地方记录是哪 5 个。** 同时 `hasScanCapability` 这个名字也是错的——它已经服务于 ALTER 列 DDL 与 ANALYZE,与「扫描」无关。 + +**(b)11 个镜像方法给同一个事实开了第二个可覆写入口。** 目前 0 个连接器覆写它们,所以没出事——但那是运气,不是设计保证。一旦某个连接器覆写了 `Connector.requiresParallelWrite()` 而没同步改写计划提供者,两个答案就会分叉,而这种分叉**没有任何测试能捕获**。 + +**(c)新连接器作者无法预判该用哪一套。** 根因很直接:**`fe-connector-api` 没有 `package-info.java`**(实测零命中),也没有任何地方写下「什么该放哪一层」。`ConnectorCapability` 的类文档只解释了一条边界(写操作与 sink 特性不放枚举、放 provider),而这条边界本身也是逐项拍的:`SUPPORTS_SORT_ORDER`(建表期的 ORDER BY 子句门)在枚举里,`requiresFullSchemaWriteOrder`(写路径的行序)在 provider 上,两者都属于「写能力」。 + +### 5.3 建议 + +**第一步(零风险,最该先做):把规则写下来。** 新增 `fe-connector-api/package-info.java`,规定**三层且仅三层**: + +- **第一层「整块子系统有没有」**:`Connector` 上的 provider / ops 取得器,缺席用 `null`(引擎已按 `null` 判定,改 `Optional` 是纯变更噪音)。 +- **第二层「某块子系统内部的开关」**:该 provider 自己的 `supportsXxx()` / `requiresXxx()`,一律默认 false,引擎先拿到 provider 再问。**禁止在 `Connector` 上做镜像转发。** +- **第三层「引擎在拿不到 provider 时也必须问的静态规划开关」**:`ConnectorCapability` 枚举,且一律支持「连接器级 ∪ 按表级」的加法解析。凡是与某个 provider 一一对应的开关不得进枚举。 +- 「值缺失」用 `Optional`;「整块子系统缺席」用 `null`。这两者的区分写进规则。 + +**第二步:把按表能力从字符串升级为类型化集合。** 给 `ConnectorTableSchema` 加建造器与 `Set` 字段,删掉 CSV 键。已核实这个类**不参与 Gson 持久化**(只是进程内 schema 缓存值),没有兼容负担。同时把引擎侧 9 处直读能力集合的地方统一改走一个入口(`hasCapability`,实现为「连接器级 ∪ 本表级」),这样「哪些能力支持按表细化」从 5/13 变成 13/13,规则从「取决于引擎具体在哪读」变成「全部一致」。 + +**第三步:删掉 11 个镜像方法**,改为公共模块里的 `final` 静态派生器(真源唯一性由语言保证),并顺手补齐「7 个写特性只有 4 个有按表形态」的对称性缺口——按表形态是免费的,因为 `getWritePlanProvider(handle)` 默认就回落到连接器级。 + +--- + +## 六、主题三:大接口把互不相干的职责捆在一起 + +### 6.1 现状规模 + +| 接口 | 行数 | 方法数 | 混在一起的职责 | +|---|---|---|---| +| `ConnectorTableOps` | 504 | 46(全部有默认实现) | 表句柄解析 / 系统表 / 读 schema / 渲染建表语句 / 列句柄 / 视图(5 个)/ 建删改表 / 列演进(11 个,含嵌套列)/ 分支与标签(4 个)/ 分区字段演进(3 个)/ 主键 / 表注释 / 执行裸 SQL / 透传查询取列 / 构造 thrift 表描述符 / 分区列举(3 个) | +| `ConnectorScanPlanProvider` | 542 | 24(23 个有默认实现) | 分片生成(4 个重载 + 批量 + 流式)/ 能力开关(4 个)/ 列分类 / 压缩类型调整 / 扫描节点属性(2 套)/ EXPLAIN 渲染 / 剖析信息 / thrift 参数填充 / 删除文件回读 / 序列化表回读 / 查询生命周期(释放读事务) | +| `Connector` | 362 | 34(1 个抽象) | 元数据工厂 / 三类 provider 取得器(各 2 个)/ 9 个写特性镜像 / 能力集合 / 存储属性派生 / 属性描述符(2 个死方法)/ 连通性测试(3 个)/ REST 透传 / 缓存失效(4 个)/ 事件源 / schema 缓存过期时间覆盖 | +| `ConnectorContext`(装配层) | 415 | 19 | 目录身份 / 环境变量表 / HTTP 安全钩子 / JDBC 地址消毒 / 认证包装 / 元数据失效通知 / 兄弟连接器工厂 / 凭证归一 / 存储地址归一(3 个重载 + 1 个批量)/ BE 文件类型 / broker 地址 / BE 存储属性 / BE 连通性探测 / 类型化存储属性 / 文件系统 / 空目录清理 | +| `ConnectorMetadata` | 235 | 自身 11 个,加上继承的六个 `Ops` 子接口共约 81 个 | 六个子接口 + 多版本快照 / 时间旅行 / 重写文件范围 / Top-N 延迟物化信号 | + +### 6.2 判断:哪些是真问题,哪些不是 + +**不是问题的部分(不要照抄 Trino 改回去)**: + +- **入口接口的聚合形状本身**。Trino 的 `Connector` 同样是「元数据工厂 + 若干 provider + 属性描述 + 生命周期」的聚合。要收的是**镜像转发和缓存失效那一族**,不是聚合本身。 +- **元数据接口很宽**。Trino 的 `ConnectorMetadata` 是 100 多个全默认方法的巨接口,靠文档告诉实现者「最少实现集」。所以 46 个全默认方法在这个坐标系里并不异常——**目标不是拆到多小,而是分域 + 每域写清最少实现集**。 + +**是真问题的部分**: + +- **`ConnectorTableOps` 里混着四组只有一个数据源用得上的方法**:分支与标签(4 个)、分区字段演进(3 个)、执行裸 SQL、透传查询取列。它们对其余连接器是纯噪音。 +- **`ConnectorScanPlanProvider` 混了三个不同抽象层次**:分片生成(连接器的核心职责)、能力开关(应属第二层规则)、以及 thrift 填充与「回读引擎刚写完的结构」这类协议细节。其中 `getDeleteFiles(TTableFormatFileDesc)` 让连接器去读**自己刚填进去的** thrift 子结构,`releaseReadTransaction(String queryId)` 把查询生命周期挂在一个「扫描计划提供者」上——这两个都是职责错位。 +- **`ConnectorContext` 上挂着 `sanitizeJdbcUrl`**:一个只有 JDBC 连接器用得上的方法,长在所有连接器共享的引擎服务接口上(见主题五)。 + +### 6.3 建议 + +有一个非常有利的事实:**`ConnectorTableOps` 在全仓库没有任何一处被当成静态类型使用**——只出现在注释与文档链接里。这意味着**把它按域拆成多个父接口是零破坏重构**:连接器一行不改、编译期完全兼容。这是整套整治能分批落地的最大杠杆。 + +建议的形状: + +- `ConnectorTableOps` 按域拆成 6 个父接口(表基础 / 视图 / 表级 DDL / 列演进 / 快照引用管理 / 分区列举),`ConnectorTableOps` 保留为它们的聚合,**每个父接口的类文档写清「最少实现集」**。零破坏,可当天合入,并为后续每一批划定域边界。 +- `ConnectorScanPlanProvider` 拆三层(分片生成 / 能力开关 / 协议填充),收敛 4 个 `planScan` 重载(现状是引擎只调最宽的那个,而抽象方法是最窄的那个,导致连接器出现**不可达的覆写**)。 +- `ConnectorContext` 把存储相关的 9 个方法收成一个服务对象。**这一批高危**:iceberg 与 paimon 各有一个逐方法转发的包装类,用于把线程上下文类加载器钉到插件加载器上;改动必须做插件包重部署冒烟验证。 + +--- + +## 七、主题四:没有调用方或没有实现方的接口面 + +### 7.1 为什么这件事比看起来严重 + +死接口面的成本不是占空间,是三种真实伤害: + +1. **强制实现的死方法在向每个新连接器收税。** `ConnectorScanRange.getRangeType()` 是抽象方法,8 个连接器全部实现了它,返回值全仓库无人读取。连 `fe-core` 的 4 个测试匿名类都得为满足这个抽象方法写一遍实现——这就是它的传染成本证据。 +2. **文档错误制造排查浪费。** `ConnectorScanRangeType` 的文档说「引擎据此选择 thrift 结构」,这是错的(真正决定的是 `populateRangeParams` 的多态覆写与格式类型)。一个连接器作者改了这个枚举发现不生效,会去查引擎,查不到。 +3. **有的死接口留着比删掉危险。** `ConnectorPushdownOps.applyLimit` 零实现,而它的调用时机(在残余谓词构建之前)**绕过了「剥离隐式类型转换时禁止把 LIMIT 下推到数据源」这条安全抑制**。哪天有人实现了它,就会得到错误结果。 + +### 7.2 可以直接删(纯公共模块内部,零实现零调用零持久化) + +以下每一条都经过全仓库重扫确认(含 `fe-core`、8 个连接器、测试、be-java-extensions、服务发现配置与反射字符串): + +| 删除对象 | 依据 | +|---|---| +| `ConnectorPropertyMetadata` 整类 + `Connector.getTableProperties()` + `Connector.getSessionProperties()` | 全仓 15 处命中全在类自身与两个取得器内部。注意:渲染建表语句走的是完全不同的 `PluginDrivenExternalTable.getTableProperties()`(返回 `Map`),同名不同义 | +| `ConnectorPartitionHandle` | 全仓仅 1 处命中(自身声明),无序列化契约 | +| `ConnectorTransactionHandle` | 空标记接口,无任何接口以它为参数或返回值。它的文档给出的存在理由(「既有接口以不透明句柄传递事务」)在代码里可验证为假 | +| `ConnectorEventSource.getCurrentEventId` | 零调用;唯一实现内部在轮询方法里自己做了同样的短路。文档声称「引擎用它先探后拉」不成立 | +| `ConnectorScanPlanProvider.estimateScanRangeCount` | 全仓 2 处命中(声明 + jdbc 的一个常量实现),零调用 | +| `ConnectorPushdownOps.applyLimit` + `LimitApplicationResult` | 零实现,且绕过安全抑制(见上) | +| 两个 `ApplicationResult` 上的 `precalculateStatistics` | 从 Trino 直译,Doris 没有「下推后重算统计」这个机制,零消费方,三个实现一律传 false——每个实现者都得为一个自己不理解也没人读的布尔位做决定 | +| `ProjectionApplicationResult` 的投影列与赋值 + `ConnectorColumnAssignment` 整类 | 引擎只取句柄,连接器被要求构造引擎会丢弃的数据 | +| `ConnectorMvccSnapshot` 的描述与时间戳字段 | 零生产方零消费方,只有公共模块自带单测在用 | +| `ConnectorTableStatistics.UNKNOWN` / `ConnectorColumnStatistics.UNKNOWN` | 零使用,且其文档「不可用时用 UNKNOWN」与统计接口的 `Optional.empty()` 约定自相矛盾(「未知」有三种表达) | +| `ConnectorPartitionValueDef` 整类 + `ConnectorPartitionSpec.getInitialValues` | 生产路径恒空(类自己的注释都写明恒空),真正被消费的是一个布尔位 | +| `ConnectorCreateTableRequest.isExternal` | 零消费者、零文档 | +| `ConnectorTestResult` 的子组件机制 | 零生产写入,且 `equals` 忽略该字段(本身也是隐患);引擎只消费成功标志与消息 | +| `ConnectorType` 的 4 个整表子列表取得器 | 零调用(实际都走按索引访问器) | +| `ConnectorViewDefinition.dialect` | 引擎零读取(视图体按会话方言解析),hive 只能编造一个占位符值。类文档「引擎按该方言解析」是错的 | +| `ConnectorProcedureOps.execute` 的 WHERE 条件参数 | 生产恒为 null | +| `MetastoreChangeDescriptor.forTable` 的「改名后表名」参数 | 4 个生产调用点全传 null,且该工厂强制另一个字段为 null,而引擎的改名处理要求两者都非空——误用会静默走错分支 | +| `ConnectorTableSchema.tableFormatType` | 零读取,取值空间未定义(一半是数据源名一半是文件格式名),且与**活的** `ConnectorScanRange.getTableFormatType()` 同名不同义。**删除时必须点名到类**,否则会误删协议字段 | + +### 7.3 需要连带改连接器的删除 + +| 删除对象 | 连带改动 | 依据 | +|---|---|---| +| `ConnectorMetadata.getProperties` | 删 5 个连接器的覆写 | 零生产调用方;命名也含混(叫「连接器级属性」却挂在每会话的元数据对象上) | +| `ConnectorTableOps.listPartitionValues` | 删 hudi / paimon / maxcompute 三个实现与对应单测 | **零生产调用方**。文档声称的分区值表函数实际走 `listPartitions`(`PluginDrivenExternalTable.java:898-899`,在 `getNameToPartitionValues` 里);另一条分区系统表路径走 `listPartitionNames`(`MetadataGenerator.java:1270`)。两条都不经 `listPartitionValues`。删除后「列分区」从三套收成两套 | +| `createTable` 与 `dropDatabase` 各一个旧宽度重载 | 把不支持时的抛出点移到保留的宽形态里 | 零实现零调用。`createTable` 的降级默认会**静默丢弃** PARTITION BY / 分桶 / EXTERNAL / IF NOT EXISTS。今天不会触发,但它是留给下一个新连接器的陷阱:只实现窄签名就会「建表成功但分区丢了」 | +| `ConnectorScanRangeType` 整个枚举 + `ConnectorScanRange.getRangeType()` + `ConnectorScanPlanProvider.getScanRangeType()` | 删 8 个连接器的实现 + 3 个覆写 + 4 个测试匿名类的实现 + 一行属性写入 | 4 个值里只有一个有生产者,引擎从不读两个取得器,写出的属性键在 BE 侧零命中。**这是本轮最有价值的一条**:删掉之后 `ConnectorScanRange` 的必须实现方法从 2 个降到 1 个 | +| `ConnectorMetaInvalidator` 整个接口 + `ConnectorContext.getMetaInvalidator()` + 引擎侧实现 | 删 iceberg / paimon 两个包装类的转发与相关测试替身 | 见主题十的详细说明 | +| `ConnectorTableOps.getPrimaryKeys` + `ConnectorTableSchema.PRIMARY_KEYS_KEY` | 删 paimon 的一行写入 | 主键有两套并存机制,**两套都没有引擎生产读取方**。流式作业的主键走的是 `fe-core` 遗留的另一条路径,不经这套接口 | + +### 7.4 关于删除节奏的一点说明 + +不建议「先加过时标注、下个版本再删」:这些都是内部接口,尚未有仓库外的实现者;过时标注只会让公共接口再长一岁而不缩小。分批 + 每批全反应堆编译验证(**含测试源**)已足够安全。 + +有两条是**判断题不是事实题**:`isExternal` 与 `getPrimaryKeys` 背后都有「未来可能真需要」的合理场景(元存储的托管/外部表之分、流式作业的外表主键)。倾向删,理由是「零消费者的字段留在公共请求对象上」本身就是误导;但如果决定保留,那**必须同时补契约文档并让至少一个连接器真正消费它**——不允许维持现状(零消费 + 零文档)。 + +--- + +## 八、主题五:公共模块里的数据源专有语义 + +### 8.1 第一类:直接以某个数据源命名或只服务于它 + +| 位置 | 现象 | 建议 | +|---|---|---| +| `Connector.executeRestRequest`(`:303`) | REST 透传逃生门,文档写「例如 Elasticsearch」。唯一实现方是 ES,唯一调用方是一个按类型名 `=="es"` 硬判的 REST 端点 | 摘成独立可选接口,由 ES 连接器实现。**那个 REST 端点自己的类型判定不动**——它本身就是 ES 兼容 API,按类型收窄是正确边界;缺陷在于把 ES 形状的方法挂在所有连接器都继承的入口接口上 | +| `ConnectorContext.sanitizeJdbcUrl`(`:79`) | JDBC 地址消毒长在中立的引擎服务接口上,且「使用任何 JDBC 地址前必须调用」的契约只有 JDBC 连接器遵守——iceberg 与 paimon 的 JDBC 元存储把用户地址直接交给第三方 SDK 建连 | 改名为中立的出站地址消毒,契约收口为「连接器**自行**建立连接时必须调用;第三方 SDK 内部建连不在覆盖内」。另可作为独立安全增强给 iceberg / paimon 补调用 | +| `ConnectorPartitionValues.HIVE_DEFAULT_PARTITION` | 公共接口里唯一的数据源品牌字符串常量,且中立命名的 `normalize()` / `isNullPartitionValue()` 只有 hive 语义成立 | **常量不删不改值**(它是分区名身份与 BE 列路径解析的字节兼容基础,物化视图与表函数已持久化这些名字),只做中立化命名 + 保留旧名别名一轮;把带 hive 与 hudi 语义的三个归一方法**下沉到唯一的生产调用方**(hudi 连接器) | +| `ConnectorScanRange.isNativeReadRange()` | 名字中立,存在的唯一目的是拼某个连接器的 EXPLAIN 行,且只有它实现 | 去掉文档里的源专属键名,或按需下沉 | +| `ConnectorScanPlanProvider.getSerializedTable(Map)` | 名字通用、语义未定义,实现只是把自己写进属性表的一个源专属键原样取回 | 定义清楚或下沉 | +| `ConnectorValidationContext.requestBeConnectivityTest` | 名义中立,实质只能是 JDBC:字节数组里偷运一个 JDBC 表结构,整数是另一个枚举的序号 | 补文档说清载荷契约;结构性中立化需跨 thrift 与 BE,不属本轮 | + +### 8.2 第二类:名字中立,但语义只对一个数据源成立(更危险) + +- **`ConnectorWritePartitionField`**:`transform` 是某个数据源分区变换的 `toString()` 文本,`fe-core` 直接字符串比对 `"identity"`,`sourceId` 字段的文档自称是那个源的字段编号。**但这不能简单合并或改值域**——已核实 BE 侧用正则解析带括号的形式(如 `bucket[16]`)并直接做字符串比较,FE 把该串**原样**塞进 thrift。所以「变换串含参数又并列一个参数字段=冗余」这条不成立,那是 FE 与 BE 有线格式的镜像。可做的是:在公共模块定义词表常量与 `isIdentity()`,把有线格式写进文档,让 `fe-core` 不再出现裸字面量比较。 +- **`BranchChange` / `TagChange` / `DropRefChange`**:字段集是某个数据源快照引用模型的一比一映射(含快照 id 与三个保留期旋钮),却挂在所有连接器共享的表操作接口上。建议把字段名改成 SQL 级中立名(对齐公共 SQL 语法里的用词),**不建议**把快照 id 改成不透明类型——可表达性上限由公共 SQL 语法决定,改接口不增加任何表达力。 +- **`event` 包**(`MetastoreChangeDescriptor` / `ConnectorEventSource` / `EventPollRequest` / `EventPollResult`):整包是某种元存储通知日志模型的中立包装(单调递增的长整型事件游标),唯一实现者就是那个元存储。**不建议改类名或把游标改成不透明字符串**:游标是长整型这个形状是引擎侧编辑日志与主从上界收敛共同决定的,改类型要先解决编辑日志兼容和「不可比较的游标如何算从节点上界」。本轮只在文档里写清「游标必须是可比较的数值 id」这条前置假设。 +- **`ConnectorMetadata.applySnapshot`** 的公共契约把某个数据源专有的扫描选项键写成了中立接口的回退规则,公共模块自带单测也硬编码了那个键名。建议改成「连接器自行决定如何承载,接口不规定任何选项键名」。 +- **`ProcedureExecutionMode.DISTRIBUTED`** 的结果 schema 被硬编码成某个数据源某个过程的四列(在 `fe-core` 的 `ConnectorRewriteDriver` 里)。建议把结果 schema 交还连接器(与单次调用模式对称),引擎只负责编排并把每组统计原样回传。 +- **`SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE`** 的语义被定义为「某个数据源风格的 schema 变更子句集」——中立名字承载单源定义。只需改述文档。 +- **`ConnectorSession.getSessionId`**、**`ConnectorContext.vendStorageCredentials`**、**`ConnectorDelegatedCredential.Type`** 的文档把某个数据源的认证/SDK 语义写成了唯一动机。纯措辞问题,一次性文档批处理。 + +### 8.3 第三类:通用引擎代码里残留的数据源分支 + +`fe-core` 的通用扫描节点 `PluginDrivenScanNode` 里还有 ES 专属分支:`:559-563`(EXPLAIN 输出某个 ES 参数)与 `:1829-1835`(往 ES 专属属性里塞 limit)。这违反了「通用节点保持与连接器无关」这条既定纪律,而且**接口侧已经有承接点**——ES 的扫描计划提供者已经覆写了 EXPLAIN 追加与扫描级参数填充两个方法。建议搬进连接器,把引擎侧需要的三个判据(下推的 limit、是否所有过滤都已下推、批大小)以中立的合成键传入(同文件已有这套机制的先例)。 + +--- + +## 九、主题六:抽象泄漏 —— 两套相反的 thrift 规则 + +这是本轮我认为**最值得单独拎出来**的一致性问题,因为它不是某个方法的瑕疵,而是两个公共模块在同一个问题上采用了相反的规则。 + +**`fe-connector-spi` 刻意避开 thrift**: +- `ConnectorContext.getBackendFileType(...)` 返回**枚举名字符串**(如 `"FILE_S3"`),文档明写「返回枚举名(一个普通字符串)以使本接口保持 thrift-free,连接器自己映射回去」。 +- `getBrokerAddresses()` 返回中立的 `ConnectorBrokerAddress` 主机端口对,而不是 thrift 的网络地址结构,理由同上。 +- `testBackendStorageConnectivity(int storageBackendTypeValue, ...)` 更进一步——把一个 thrift 枚举**的整数值**当参数传。 + +**`fe-connector-api` 完全不避**: +- `ConnectorScanPlanProvider` 直接 import 三个 thrift 生成类(`TFileCompressType`、`TFileScanRangeParams`、`TTableFormatFileDesc`)。 +- `ConnectorScanRange` import 两个(`TFileRangeDesc`、`TTableFormatFileDesc`)。 +- `ConnectorSinkPlan` 以 `TDataSink` 为核心,`ConnectorWriteHandle` 用 `TSortInfo`。 +- `ConnectorTableOps.buildTableDescriptor` 返回 `TTableDescriptor`,而且是用**内联全限定名**写的(`ConnectorTableOps.java:464`)——这种写法本身就是「知道这里不该出现它」的痕迹。 + +**后果**:一个新连接器作者读 `spi` 会以为「公共接口不碰 thrift,引擎会用中立类型和我交互」,读 `api` 会发现自己必须直接构造引擎的 thrift 对象。两种预期在同一次实现里冲突。而 `spi` 那条「thrift-free」的规则也没有真正兑现——它的整数参数形态其实是**比直接用类型更差的耦合**:丢了类型安全,还是绑死了枚举取值。 + +**建议**:接受「面向 BE 的负载必须走 thrift」这个结构性事实(根因是 BE 是 C++ 且无插件机制),但把规则统一写下来,放进 `fe-connector-api/package-info.java`: + +- 明确 thrift 只允许出现在**面向 BE 的协议边界**上(分片参数、数据 sink、表描述符),并列出当前的完整清单; +- 明确**不再新增** thrift 入参的方法; +- 把 `spi` 侧那三处「为了 thrift-free 而做的字符串/整数化」的理由写清,或者反过来——既然 `api` 已经依赖 thrift 而 `spi` 又依赖 `api`,那这三处的字符串化收益为零,可以考虑统一; +- 三处可行的纯值搬运中立化(压缩类型调整、写句柄的排序信息、`getDeleteFiles` 改成 `ConnectorScanRange.getDeleteFilePaths()`)属于低优先的洁净化,可排在后面或不做。 + +--- + +## 十、主题七:语义与契约不清 + +### 10.1 用字符串表把结构化信息偷运过边界 + +三处,都违反「不应用 `Map` 偷运结构化信息而不定义键契约」: + +**(a)`ConnectorTableSchema` 的保留键。** 7 个 `__internal.` 前缀键承载:分区列、主键、按表能力集合、分布列,以及三段**预渲染的 Doris SQL 片段**(位置、PARTITION BY 子句、ORDER BY 子句)。前四个应该类型化(见主题二)。后三段**建议保留在属性表**:让连接器预渲染确实把 Doris 的反引号规则泄漏进了插件,但把它搬回引擎就要求引擎理解各数据源的变换语法,直接违反「引擎不解析属性」的既定架构目标。这是有意取舍,只应在文档里写清「连接器负责按 Doris 反引号规则转义」。 + +**(b)扫描节点属性表。** `ConnectorScanPlanProvider.getScanNodeProperties` 返回 `Map`,但**键的契约不在公共模块**——一半散在 `fe-core` 的私有常量里(`PluginDrivenScanNode.java:127-130`,其中还有带数据源名前缀的键),一半散在各连接器的字面量里。新连接器只能靠读引擎源码抄字符串。建议在公共模块建一个键常量类,把契约集中并公开。 + +**(c)`ConnectorWriteHandle.getWriteContext()`。** 用无键契约的字符串表偷运静态分区值,方法名与内容不符——**它自己的文档都承认了这一点**。 + +### 10.2 数值的单位与「未知值」没有统一约定 + +- **`ConnectorScanRange.getLength()`** 文档写「字节数」,但 MaxCompute 在按行偏移的模式下返回**行数**,而引擎无条件把它累加进「总文件大小」。这是一个已知的项目级事实(分片长度的语义因连接器而异),正确的处置不是统一语义,而是**按大小的通用特性必须走能力 opt-in**,并把文档改成据实描述。 +- **`ConnectorMvccSnapshot.getSnapshotId()`** 没有定义「未知 / 无快照」的取值:建造器默认 0,而引擎的空 pin 判定与某连接器的统计逻辑都按 `-1` 或 `< 0` 判断。只带属性的 pin 会静默拿到 0。 +- **`ConnectorPartitionInfo.lastModifiedMillis`** 契约是 epoch 毫秒,hudi 填的是它自己的时刻串(`yyyyMMddHHmmssSSS` 形式的数字)。**这是一个有实际后果的缺陷**:引擎拿它与墙上时钟相减做 SQL 缓存的窗口门禁,非 epoch 毫秒会让这类查询的 SQL 缓存**永久不启用**(「永久」的机制是引擎把当前时间与这个更大的数取最大值,于是差值恒为 0,不是「等一会儿就好」)。**影响面(后续核实后收窄)**:仅在会话开关打开、且表是**分区** hudi 表时可见(无分区 hudi 表的版本令牌本来就是 0、本来就不可缓存)。修法在连接器侧(转换成 epoch 毫秒),引擎零改动;注意时刻串不带时区,按表配置的时区生成,转换时要读表配置而不是用全局时区。 +- **统计接口的「未知」有三种表达**(`Optional.empty()` / `UNKNOWN` 常量 / 特殊数值),其中 `UNKNOWN` 常量零使用且与接口约定矛盾。 + +### 10.3 异常契约缺失 + +`DorisConnectorException` 只是一个空壳基类,公共模块**没有定义异常契约**。实际上连接器混用四个异常族,而引擎只翻译其中一族。表现出来的具体问题: + +- `resolveTimeTravel` 的契约写「不支持的规格返回空」,三个连接器全部改为**抛异常**,且异常类型三家不一致。 +- `listFileSizes` 的契约明确写「出错必须返回空、不得抛异常」,**唯一实现故意抛出,并有单测锁死抛出行为**。这一条经核实**实现是对的、文档是错的**:显式的采样分析必须 fail loud,吞掉异常会让采样因子静默塌成 1.0,产出错误的统计。 +- `ConnectorProvider.create()`(无参版,`:93`)为满足插件工厂的类型上界而存在,实现是无条件抛异常——违反父接口契约。目前不可达,最小处置是标注过时 + 文档写明「任何调用都是错误用法」。 + +**建议**:在 `package-info` 里定义异常分层(用户错误 / 配置错误 / 远端不可用 / 内部错误),规定连接器一律抛 `DorisConnectorException` 的子类,并给出「什么时候该 fail loud、什么时候该静默降级」的判据。这比逐个方法补文档更有效,因为它给的是规则而不是个例。 + +### 10.4 生命周期与线程模型没写进契约 + +- **`Connector.getMetadata` 返回的实例**:引擎按「每语句一实例、语句结束时关闭」使用,但契约没写;同时 `ConnectorMetadata.close()` **没有任何连接器实现**(共享客户端由 `Connector.close` 释放)。建议在两处交叉写清「每语句一实例、语句结束由引擎关闭、关闭必须幂等、单实例不跨线程共享」,并互相引用到语句作用域类。 +- **统计接口在引擎的后台统计线程上被调用,而引擎不钉线程上下文类加载器**——公共接口完全没写这个契约,唯一的实现靠自己补救。这在本项目里是一类已知的高危坑(跨插件类加载器的按名反射)。必须写进契约。 +- **`ConnectorStatementScope` 关闭后的状态未定义**,实现允许「关闭后复活」,此后写入的可关闭值永远不会被关闭。 + +### 10.5 方法名与行为不符 / 重载堆叠 + +- `ignorePartitionPruneShortCircuit()` 是双重否定命名。 +- `ConnectorType` 这个名字指的是**数据类型**("INT" / "DECIMAL"),不是「连接器的类型」。这个命名会让每个第一次读代码的人误判(我自己就误判了一次)。改名成本已不可承受(同一词表被下推侧、schema 映射侧与引擎反向构造三方共用),**只补文档**。 +- `ConnectorType` 用 7 个构造器(6 个便捷 + 1 个规范)+ 9 个静态工厂 + 5 组平行列表承载可选元数据,且 `equals` 只覆盖其中一部分(这一点是有意的、文档也写清了,不算缺陷)。 +- `planScan` 四级望远镜重载:引擎只调最宽的一个,抽象方法却是最窄的那个。 +- `getScanNodeProperties`(返回 `Map`)与 `getScanNodePropertiesResult`(返回包装对象)两个面:引擎只调后者,6 个连接器只实现前者并经默认委派生效。**这不是两套竞争机制**(后者的默认实现显式包装前者),但**同时覆写两者会静默丢掉一个**——必须补一句文档,并把包装对象「用调了哪个构造器」隐式编码一个布尔位的做法改成两个具名静态工厂。 + +--- + +## 十一、主题八:实现与接口定义不符 + +这一节回答用户提出的第二个问题(「实现是否符合接口定义」)。共 24 条,分四类。 + +### 11.1 四个有实际用户可见后果的缺陷 + +**(1)三路以上的 OR 谓词在某个连接器上会丢行。** `ConnectorOr` 被文档化为 N 元(`getDisjuncts()` 返回列表),但 trino 连接器的谓词转换器只读**前两个** disjunct(`TrinoPredicateConverter.java:114-118`)。`WHERE a=1 OR a=2 OR a=3` 会在数据源侧按 `a=1 OR a=2` 过滤——**结果少行**。修法:按列做域并集折叠全部 disjunct;同时给 `ConnectorOr` 构造器加「至少两个」校验与防御性拷贝。 + +**(2)复杂类型的字段名会被丢掉,引擎静默编造替代名。** `ConnectorType` 的字段名列表与子类型列表是两条平行列表,两者的对应关系**既没有契约也没有校验**(构造器不校验长度)。trino 连接器构造 STRUCT 时丢掉了字段名,引擎侧于是编造 `col0` / `col1`。修法:构造期校验,并在文档里把平行列表的对应关系写成契约。 + +**(3)`WHERE 列 <=> 5` 这种空值安全比较会被 paimon 下推成「该列 IS NULL」。⚠ 后续核实:这条默认潜伏,不是默认可观察。** + +先说这个重要更正:优化器有一条改写规则(`NullSafeEqualToEqual`),在**过滤条件位置**只要一边不可为空(字面量恒不可为空)就会先把 `列 <=> 5` 改写成 `列 = 5`(判据在该类的 `canConvertToEqual`:`(!left.nullable() && !right.nullable()) || (isInsideCondition && (!left.nullable() || !right.nullable()))`)。所以普通的 `WHERE c <=> 5` 通常到不了连接器的坏分支。该规则带可禁用标签,通过会话变量关掉它之后才会走到坏分支——这条复现路径是按代码推导的,**未起集群实跑验证**。 + +因此正确的定性是:**缺陷客观存在且严重(一旦到达就静默少行),但默认被优化器改写挡住,属潜伏缺陷。** 它仍然必须修——连接器的正确性不能依赖一条可以被关掉的优化规则。下面是缺陷本身: 比较算子枚举的公共契约总共只有一行文字(`pushdown/ConnectorComparison.java:27` 列出七个算子名),**没有任何地方说明右操作数可能是空值字面量、空值安全比较的语义、以及不可精确翻译时必须放弃下推(不得收窄)**。于是五个消费者各写一遍:iceberg 做对了并写了注释,trino 做对了,maxcompute 显式放弃,es 有专门测试——**只有 paimon 错了**:它先把空值字面量过滤掉(`PaimonPredicateConverter.java:245-247`),于是 `:173-174` 的空值安全分支**只可能在字面量非空时到达**,`WHERE c <=> 5` 被翻成 `c IS NULL`,paimon 据此做文件级裁剪,`c=5` 所在的数据文件被跳过——**静默少行,且 BE 复算补不回来**。 + +**(4)含单字符通配符或转义的 LIKE 谓词会被 paimon 收窄成前缀匹配,也会少行。** `ConnectorLike` 的全部契约就是一句「值 LIKE 模式」(`pushdown/ConnectorLike.java:23-24`),**没有**转义字符、`%` 与 `_` 的方言、正则是部分匹配还是整串锚定、大小写敏感性。paimon 于是只要「不以 `%` 开头且以 `%` 结尾」就转成前缀匹配(`PaimonPredicateConverter.java:233-238`),对 `_` 与被转义的 `%` 没有任何守卫——`LIKE 'a_c%'` 变成 `startsWith("a_c")`。ES 那侧则把 Doris 的正则原样交给 ES 的正则查询,而两者的锚定语义不同。 + +**这四条的归责(后续逐条核实后修正)**: + +- **(1)是本次迁移引入的回退**,不是上游既有缺陷。迁移前的实现在它自己的二元谓词模型下是正确完备的;缺陷来自迁移把输入换成了 N 元列表,却照抄了「读两个孩子」的写法。(上游 `master` 上也已存在同样写法的连接器文件,由迁移早期的一个 PR 带入,但 `master` 上迁移前的旧路径仍在,所以只有本分支这段是生效路径。)**提交信息里不得把它描述成上游既有缺陷。** +- **(2)是本次迁移引入的**(同一处类型映射在迁移前会判断字段名是否存在)。附带一点:迁移前对匿名字段一律用同一个名字、本身会重名,所以修复时用「名字 + 下标」是**有意偏离**迁移前行为的改进,不是平价移植。 +- **(3)(4)是 paimon 连接器的问题,经 `git show master` 逐字对比确认与上游既有实现完全相同——是上游既有缺陷的忠实移植,不是本次迁移引入的回退。** + +另外,(4)的行为后果是代码路径推断、未跑端到端验证;后续核实还发现**第三种同根因形态**:`%` 夹在模式串中间(例如 `LIKE 'a%b%'`)现在也会被当成字面前缀下推。所以修法要收紧为「含 `_` 或转义符直接放弃;剥掉末尾连续 `%` 之后主体仍含 `%` 或为空则放弃」。 + +但对本次调研而言,共同点是:**接口没有校验、没有契约,就是让这类实现错误能活下来、并且四家连接器各写一遍各错一遍的原因**。公共接口该做的是把逐算子语义与「不可精确翻译必须返回不下推」写进契约;连接器侧的两个 bug 另开修复并配端到端回归。 + +### 11.2 文档描述的用途与真实用途完全不同(文档陈旧) + +| 方法 | 文档说 | 实际 | +|---|---|---| +| `ConnectorTableOps.listPartitionValues` | 服务分区值表函数 | 零生产调用方;那个表函数走 `listPartitions`(`PluginDrivenExternalTable.java:898-899`) | +| `ConnectorScanRangeType` | 引擎据此选择 thrift 结构 | 引擎从不读它;真正决定的是参数填充方法的多态覆写 | +| `ConnectorEventSource.getCurrentEventId` | 引擎用它先探测有没有新事件再拉取 | 零调用;游标完全由拉取结果驱动 | +| `ConnectorContractValidator` | 「由每个连接器的契约测试调用 validate 来强制」 | 只有 4 个连接器的测试调用;**唯一声明了分区哈希写的 hive 恰好没调**,导致相关不变量在生产连接器上没有正样本。另外它只校验连接器级形态,而引擎读的是按表形态 | +| `ConnectorProcedureOps.getSupportedProcedures` | 引擎用它做路由与校验 | 两项都不成立:路由走执行模式,未知名拒绝由连接器在执行方法内完成;唯一读取点自身无生产调用方 | +| `ConnectorPartitionInfo` | 「不填分区值则引擎回退解析分区名」 | 引擎侧的回退已被删除、改成了元数(arity)硬校验,且调用方的 try/catch 把失败降级成「整表无分区」 | +| `ConnectorMvccSnapshot` | 「会被序列化进 BE 的扫描分片」「属性会传播到 BE」 | 引擎两件事都不做(由连接器自己的扫描计划提供者送达) | +| `ConnectorMetadata.getSyntheticScanPredicates` | 「引擎照单全收,从不按连接器区分」 | 引擎的反向转换器只认「与」+ 5 种比较 + 字符串字面量,超出直接抛异常 | +| `ConnectorPushdownOps.supportsCastPredicatePushdown` | 「返回 false 时引擎会先剥掉含类型转换的谓词」 | 只对残余谓词路径成立;`applyFilter` 路径**不剥**。而三个实现 `applyFilter` 的连接器都继承了**不安全的默认 true** | + +最后一条需要人拍板:建议**把默认值改成 false(安全侧)**,让连接器显式声明自己能正确处理隐式类型转换的谓词语义,并把文档改成据实描述两条路径的差异。彻底修法需要在引擎侧给被剥壳的比较打标记,让连接器能自查——因为表达式模型里**没有类型转换节点**,剥壳发生在引擎侧,连接器目前无从自查。 + +### 11.3 契约被实现违背 + +- `listFileSizes` 的异常契约(见 10.3,**实现对、文档错**)。 +- `resolveTimeTravel` 的「返回空」通道(见 10.3,**实现对、文档不完整**——引擎侧只有「未找到」一种用户文案,空值无法表达「语法不支持」,所以抛异常是必要的;应把这条写进契约)。 +- `sanitizeJdbcUrl` 的「必须调用」契约只有一个连接器遵守(见 8.1)。 +- `ConnectorContext.getHttpSecurityHook` 的用法契约(对外 HTTP 前后调用)也只有一个连接器遵守,iceberg / paimon 的 REST 目录用用户提供的地址发 HTTP 却不过这个防护钩子。 +- `ConnectorTransaction` 契约声明回滚与关闭可重复调用,hive 实现不满足(二次回滚会在已关闭的线程池上再提交任务)。 +- `ConnectorSchemaOps` 的库名参数在兄弟方法之间**一个是本地名一个是远端名**,文档完全没约定。 +- `listPartitions` 的过滤参数在生产中恒为空,却被三个连接器重新解释成「绕过缓存」的开关。 +- `WriteOperation.UPDATE` 从来不是可声明的写能力:无连接器在支持集合里声明它,引擎准入只查删除或合并——**只支持删除的连接器会被放行执行更新**。 +- `getUpdateCnt()` 契约只写「受影响行数」,但引擎依赖一个未在接口上定义的 `-1` 哨兵(只写在某个默认实现的类注释里)。 +- EXPLAIN 路径传给写侧 EXPLAIN 方法的写句柄是**伪造的**(覆写标志恒 false、上下文恒空、排序信息恒 null),接口文档未告知。 + +### 11.4 按表能力 CSV 的静默丢弃(见主题二) + +补一个实现细节,它是这一簇里最关键的:**「未知能力名」要分两种**——不是任何合法能力常量名的 token(纯拼写错,必然是 bug,应抛异常)vs 是合法常量但不在可细化子集里(hive 有意反射兄弟连接器全集,必须静默忽略)。只有区分这两者,才能既 fail loud 又不破坏异构网关。 + +--- + +## 十二、主题九:对称性缺口 + +- **写特性的按表形态只做了 7 个中的 4 个**。异构网关拿不到另外 3 个的按表答案,注释里自陈「靠两边恰好都为 true 侥幸成立」。按主题二第三步的做法,这个缺口是**免费**补上的。 +- **hive 网关为 12 个 DDL 转交了 iceberg 兄弟,却漏了 5 个按路径寻址的列 DDL**(含唯一入口 `modifyColumnComment`)——后果是 Hive 元存储目录里的 Iceberg 表上 `MODIFY COLUMN COMMENT` 直接不可用。这是实现缺口,不是接口缺陷,但值得单独修。 +- **列统计只有「单列、无快照」一种形态**:表统计有带快照的重载而列统计没有,且底层的批量能力被包成逐列往返。 +- **建表请求没有主键字段**:paimon 只能从属性表里偷运主键,而读侧却有一个一等公民的 `getPrimaryKeys()`(它自己又是死接口)。 +- **`apiVersion` 不匹配在两条引擎路径上行为相反**:一条告警后静默跳过,一条直接抛。 +- **「改写表句柄」的一族方法分裂成两种返回约定**,且 `applySnapshot` 的文档声称与 `applyFilter` 同构,实际不同构。 + +--- + +## 十三、主题十:`api` 与 `spi` 两个模块的边界说不清 + +这一条是我自己在通读时发现的,也是完备性核查要单独确认的一项。 + +**事实**:`spi` 依赖 `api`(单向,方向本身是清晰的)。`api` 的模块描述说自己是「面向消费方的 API:核心 Connector 接口、元数据子接口、不透明句柄、会话、值对象、能力枚举」;`spi` 的包文档说自己是「连接器实现必须履行的 SPI 契约,主入口是 `ConnectorProvider`」。 + +**问题**:这个划分说不清,且现状自相矛盾。 + +- 如果边界是「谁实现」:`spi` 放「引擎实现、连接器消费」的东西(`ConnectorContext`、`ConnectorMetaInvalidator`、`ConnectorBrokerAddress`)+ 服务发现入口(`ConnectorProvider`),`api` 放「连接器实现、引擎消费」的东西。**但这条规则被大量违反**:`ConnectorSession`(引擎实现、连接器消费)、`ConnectorHttpSecurityHook`(引擎实现)、`ConnectorValidationContext`(引擎服务)全在 `api`。 +- 如果边界是「thrift 洁净度」:`spi` 声称 thrift-free,但它依赖的 `api` 就依赖 `fe-thrift`(见主题六),所以这条也不成立。 +- 实际效果上,两个模块**总是同时出现在连接器的编译类路径上**,也都被编进 `fe-core`,所以这个拆分**没有带来任何可强制的依赖约束**——它今天只是一个命名约定。 +- **更进一步:两个模块的名字与内容整体是反着的。** 按业界惯例(也是 Trino 的用法),「连接器要实现的东西」叫 SPI,「连接器要消费的引擎服务」叫 API。而这里 `fe-connector-api`(95 个文件)装的是**连接器要实现**的接口,`fe-connector-spi`(5 个文件)装的是**连接器要消费**的引擎服务。所以问题不只是「某个类放错边」,是两个模块名整体倒置。 +- `fe-connector-spi/pom.xml:38` 的模块描述里写着它「包含 `ConnectorProvider`、`ConnectorContext` 和 `ConnectorTypeMapper`」——**`ConnectorTypeMapper` 这个类全仓不存在**(唯一命中就是这行描述本身)。陈旧描述。 +- 边界没有任何机器校验:`tools/check-connector-imports.sh` 里不含任何关于「什么该放 api、什么该放 spi」的规则。 +- 依赖方向本身**已核实无问题**:`api` 不 import `spi`(实测零命中),`spi` → `api` 单向;`fe-thrift` 在 `api` 里是 `provided`、不传递。但「`spi` 没有引擎类型」这句只对 thrift 成立——`spi` 依赖 `fe-filesystem-api`,把文件系统类型递给连接器,性质与 thrift 同类(只是那是有意的内部 API 边界)。 + +**另外,缓存失效这件事被这个边界切成了两半,而且方向相反**: + +- `api` 侧:`Connector.invalidateTable / invalidateDb / invalidateAll / invalidatePartition`——引擎通知连接器丢缓存。**这套是活的**(引擎侧 16 处调用)。 +- `spi` 侧:`ConnectorMetaInvalidator`——连接器通知引擎丢缓存。**这套零连接器调用,且引擎侧履约不了它的契约**:按分区失效传的是分区列值而引擎缓存按分区名索引,只能降级成整表;统计失效没有入口,是空操作。真正在跑的是拉模型(连接器暴露事件源 + 引擎轮询后自行失效)。iceberg 的测试注释已明确记载「连接器侧通知曾被有意移除」。 + +**建议**: + +1. **给两个模块各写一份 `package-info`,把边界规则写下来**,并按规则把放错位置的类型归位(或者明确承认这个划分只是历史产物、以「谁实现」为准并逐步归位)。 +2. **删掉 `ConnectorMetaInvalidator` 这套死的推模型**(连带引擎侧实现与两个包装类的转发),只留拉模型。这样「失效」这件事只剩一个方向、一套词汇。 + +--- + +## 十四、被推翻或收窄的说法 + +诚实记录这一节,因为它同样是结论的一部分——**误报比漏报更毒**,一份评审如果把有充分理由的设计写成缺陷,会导致把好设计改坏。 + +### 14.1 四条被推翻 + +| 说法 | 为什么不成立 | +|---|---| +| 「`preCreateValidation` 声明抛 `Exception`,引擎那条捕获引擎异常的分支对插件连接器不可达、是死代码」 | 实测为假。引擎侧校验上下文的服务方法本身会抛引擎异常(下载驱动失败时真抛),经连接器方法原样冒泡后正好被那条分支接住——它的作用正是避免把引擎自己的异常二次包装。而且收窄签名的建议不可行:那两个引擎服务方法本身声明抛 `Exception`,连接器无法在不吞掉受检异常的前提下收窄 | +| 「多版本分区视图的风格枚举只有两个值,列表分区的表无法表达」 | 该类文档已明确写出「默认路径就是列表分区 + 时间戳」,这个视图是区间分区专用的可选逃逸口。语义已写清,不存在表达缺口 | +| 「委派凭证的类型枚举与引擎侧枚举逐常量镜像,需两处同改」 | 镜像的唯一原因正是「公共接口不得暴露引擎类型」这条原则;取值域由认证协议决定,不随连接器数量增长,新增连接器无需改任何一侧;漏改风险已被按名转换的 fail-loud 覆盖 | +| 「排序键的两个值对象近乎重复,应合并」 | 三条硬约束各自决定了两者的形态:方向相反(一个是引擎→连接器的建表请求,一个是连接器→引擎的写计划应答)、标识空间不可统一(建表时表还不存在只能用列名,写侧引擎必须按输出下标取列)、命名是刻意与上游 DTO 一比一镜像 | + +### 14.2 收窄幅度最大的几条 + +- 「能力声明有 8 套并存机制」→ 实际只有 3 套构成真冗余,其余是不同形态的正当表达(详见主题二)。 +- 「`ConnectorScanRange.getRangeType()` 完全是死面」→ 枚举值确实无人消费,但公共模块的默认参数填充方法会把它写进一个属性键;删枚举要连带处理这个 BE 侧无人消费的死键。**同一个默认方法写的另一个键是活的**,不能顺手删掉整个属性方法。 +- 「`WriteOperation.OVERWRITE` 是死枚举值」→ 它在准入集合这条轴上是活的。死的只是「同一个句柄上两套编码」(布尔标志 + 枚举值)。 +- 「两个同名的 `getTableFormatType()` 都该删」→ **一死一活**:分片上的那个是活的协议字段,表 schema 上的那个零读取。删除时必须点名到类。 +- 「`sanitizeJdbcUrl` 应挪进建表校验上下文」→ 不行,它是运行时创建客户端时作为方法引用传下去的。 + +--- + +## 十五、建议的整治路线 + +按「零风险优先、能兑现承诺优先」排序。每一批都能独立编译并验证。 + +| 批次 | 内容 | 影响范围 | 风险 | 为什么排在这里 | +|---|---|---|---|---| +| **1. 写规则** | 新增两个模块的 `package-info`:能力声明三层规则、异常分层、thrift 边界与「不再新增 thrift 入参」、`api` 与 `spi` 的划分规则、生命周期与线程模型 | 仅文档 | 无 | 这是所有其它批次的判据来源。也是「新连接器作者无法预判」的直接解药 | +| **2. 文档据实** | 修正主题十一列出的全部陈旧文档;补齐单位、null、异常、生命周期契约;删掉公共接口文档里的内部工单号与任务代号 | 仅文档 | 无 | 当天可合。**收益被严重低估**:新连接器作者的主要信息源就是这些文档 | +| **3. 零破坏分域** | `ConnectorTableOps` 按域拆成 6 个父接口(已核实全仓无一处把它当静态类型用,连接器零改动);每域写清最少实现集 | 仅公共模块 | 无 | 为后续每一批划定边界 | +| **4. 删死面(第一组)** | 主题七第 7.2 节全部(约 20 项),公共模块内部 + 引擎侧三处删代码,**无连接器改动** | 公共模块 + 引擎 | 低 | 每删一项就少一份「新连接器要交的税」 | +| **5. 删类型白名单** | 落实主题四第 4.1 节(1),provider 加一个默认方法 + hudi 覆写 + 引擎侧改造 | spi + hudi + 引擎 | 中(重放语义从抛异常变为降级注册,是净改善,但要确认没有测试依赖旧行为) | **这是唯一能真正兑现「新增连接器不改公共模块」承诺的一批** | +| **6. 修四个真实缺陷** | 三路 OR 丢行、复杂类型字段名丢失(trino,**均为本次迁移引入**);空值安全比较被译成 IS NULL、含通配符的 LIKE 被收窄成前缀(paimon,**均为上游既有缺陷的移植**);hudi 的时刻单位违约 | 连接器 | 低(修 bug) | 有用户可见后果,不应排在设计整治后面。可观察性各不相同:三路 OR 丢行**当前生效**;LIKE 收窄推断成立但未经端到端确认;空值安全比较**默认被优化器改写挡住**(潜伏);字段名丢失表现为列名变成 `col0`/`col1` | +| **6b. 补齐引擎侧转发** | iceberg / paimon 两个类加载器钉桩包装类漏转发的 1~2 个方法;并把引擎必须履约的方法改成抽象或引入公共转发基类(附录 D.2) | 公共模块 + iceberg + paimon | 低 | **潜伏的类加载器分裂事故源**,而且每往上下文接口加一个默认方法就多一处。修一次即根治 | +| **7. 删死面(第二组)** | 需连带改连接器的删除(主题七第 7.3 节),其中分片类型枚举族要动 8 个连接器 | 公共模块 + 8 连接器 | 中 | 机械改动,靠全反应堆编译兜底 | +| **8. 能力载体类型化** | 按表能力从字符串升级为类型化集合(先双写一轮再拆旧);删 11 个镜像方法改静态派生器;引擎侧统一能力入口 | 公共模块 + hive + 引擎 | 中 | 顺带补齐对称性缺口 | +| **9. 中立化** | ES 的 REST 逃生门摘成可选接口;分区哨兵中立命名 + 归一方法下沉;事件源 opt-in;引擎通用扫描节点里的 ES 分支搬迁;分布式过程结果 schema 交还连接器 | 公共模块 + 5 连接器 + 引擎 | 中(EXPLAIN 文本必须逐字一致,否则端到端用例会失败) | 需要端到端测试兜底 | +| **10. 建表能力下沉** | 落实主题四第 4.1 节(2) | 公共模块 + 4 连接器 + 引擎 | **高**(错误文案与校验优先级必须逐字保持) | 风险最高,必须靠端到端测试,且要先把现有五种形态的错误文案固化成断言 | +| **11. 装配上下文拆分** | `ConnectorContext` 的存储服务收成服务对象 | spi + 全连接器 | **高**(必须做插件包重部署冒烟,验证线程上下文类加载器的钉法) | 收益是可读性,排最后 | +| **待拍板** | 连接器自声明属性接线(主题四第 4.5 节):要么接线成声明式属性,要么删掉三个死接口 | 牵动用户可见的配置位置 | 需决策 | 不宜与上面任何一批混做 | + +**统一验收口径**:全反应堆**含测试源**的 `test-compile` 通过(这是最强的单一符号级信号,禁用跳过测试编译的参数)→ 受影响连接器的单测与契约测试 → 该批可能改变行为的端到端子集 → 对被删符号复扫应为零命中。前四批不改变任何运行时行为,端到端只作兜底;从第 8 批起端到端是必需项。 + +**关于门禁的一点说明**:这批不变量(能力名合法性、写侧真源唯一)改完之后由类型系统与 `final` 修饰保证,**不建议再写 shell 或正则的静态门禁**——本仓库已有结论:那类门禁只适合存在性与前缀类不变量,要理解语言语义就会误报,而误报比漏报更毒(它会挡住合法构建)。 + +--- + +## 十六、明确不建议动的部分 + +这一节和上一节同等重要——它防止下一轮审查把同样的东西再提一遍。 + +1. **展示名兼容表**(主题四第 4.3 节):只合并去重 + 加注释冻结,不下沉。 +2. **ES 兼容 REST 端点自身的类型判定**:那个端点本身就是 ES 形状的 API,按类型收窄是正确边界。缺陷只在于把 ES 形状的方法挂在共享入口接口上。 +3. **数据类型的 token 词表**(`DECIMALV3` / `DATEV2` / `DATETIMEV2` 等):同一词表被下推侧、schema 映射侧与引擎反向构造三方共用,改名会造成两套词表分叉。只补文档。 +4. **`ConnectorCapability` 封闭枚举本体**:能力位必须有引擎消费方,新增能力必然涉及引擎改动,这不是缺陷。 +5. **预渲染的三段建表语句片段**:搬回引擎会违反「引擎不解析属性」的既定架构目标。只补文档说清转义责任。 +6. **公共模块依赖 `fe-thrift` 与按数据源开的 thrift 联合体**:根因是 BE 是 C++ 且无插件机制。只写清边界。 +7. **写侧与建表侧的分区/排序值对象不合并**:生命周期与标识空间不同,且写侧是 BE 有线契约的直接镜像。该做的是中立化文档与命名对齐。 +8. **两个单字段谓词包装类(读侧与写侧)不合并**:null 契约相反是有单测显式覆盖的有意设计。该做的是删掉公共文档里的内部工单号、补上第二条生产路径的说明、并写清「写侧谓词可能比用户的 WHERE 更宽」。 +9. **两套残差谓词协议不合并**:下标协议是当前唯一真正生效的,残差协议在引擎侧被实现成「什么都不摘」(细粒度反查标为未来增强)。合并需要先实现细粒度反查。该做的是补文档。 +10. **分区空值哨兵的字符串值**:物化视图与表函数已持久化这些名字,BE 的列路径解析也依赖它。只改命名与归属,不改值。 +11. **事件游标是长整型这个形状**:由引擎编辑日志与主从上界收敛共同决定。只写清「游标必须可比较」。 +12. **`ConnectorMetadata` 的 `Closeable` 面**:9 个实现全不覆写,但语句作用域的关闭逻辑本身并非空转。删掉会关闭一个将来必然需要的扩展点,只补幂等契约。 +13. **`ConnectorContractValidator` 不删也不搬进引擎**:它不是死代码,而是被有意限定在契约测试里调用的静态断言工具(不在目录注册期跑有客观理由:读写能力检查会构造写计划提供者,某连接器会提前建远端目录)。真正该做的是三件事:把这个仅测试使用的类从公共模块主源搬到测试构件(否则它会随 API 构件发布)、给 hive 补一条调用它的契约测试(补上那个精确的覆盖缺口)、把它扩展成同时校验按表形态。 +14. **`ConnectorScanPlanProvider` 上四个 `supportsXxx()`**:它们正是能力声明第二层的正确形态,不动。 + +--- + +## 十七、与 Trino 连接器接口设计的对照 + +对照的意义不是「照抄」,而是分清**哪些差异是合理的本地化取舍,哪些是直译后没接线的缺陷**。 + +### 17.1 合理的本地化(不要改回去) + +| 差异 | 判断 | +|---|---| +| Trino 的分片与表句柄完全不透明,由连接器自己序列化,worker 直接反序列化回连接器自己的类;Doris 必须走 thrift 的按数据源联合体 | **合理**。Trino 的 worker 是 JVM 可以加载插件,Doris 的 BE 是 C++ 且无插件机制。这是根因差异 | +| Trino 的元数据接口是每事务一个,生命周期由接口里的事务方法定义;Doris 是每语句一个 + 语句作用域 | **合理**。Doris 引擎没有「每事务一个元数据对象」的概念,语句是它的自然作用域。缺的只是把契约写清 | +| Trino 没有建表引擎名概念;Doris 有一张引擎名白名单 | **合理**。MySQL 兼容的用户可见遗留语法,不可能整体移除。只该把「外部目录的引擎名」变成连接器声明 | +| Trino 的能力协商主要靠「试着下推,不支持就返回空」;Doris 有 13 个布尔能力位 | **合理**。Doris 的优化器在很多点上需要的是**布尔前置门**(能不能开启 Top-N 延迟物化、能不能进后台自动分析、建表能不能接 ORDER BY 子句),不是「试着下推看连接器接不接」 | +| Trino 只有异步分片源;Doris 以同步分片列表为主 + 流式为可选分支 | **合理**。Doris 的扫描节点需要在计划阶段把分片信息落进 thrift | +| Trino 没有「一个目录服务多种表格式」的模型(它靠多个目录);Doris 有异构网关 | **合理且必要**。正因如此,「7 个写特性只有 4 个有按表形态」才是真缺口——这不是抄漏了,而是本地化没做完 | +| 两边的元数据接口都是上百个默认方法、一律「默认不支持」 | **一致,是本项目做得好的部分** | + +### 17.2 直译后没接线的真缺陷 + +1. **连接器属性描述符体系**:Trino 里它是活的(直接支撑建表的合法键校验与按目录设置会话属性),Doris 直译了类和两个取得器但**没有对应语法接线**,成了零实现零调用的死面。这是照抄未落地,不是取舍。(详见主题四第 4.5 节) +2. **不透明事务句柄**:Trino 里它是整个接口体系的枢轴,Doris 自己管事务、句柄从不跨接口传递,于是成了零引用的空标记接口,而它的文档给出的存在理由可验证为假。 +3. **「下推后是否需要重算统计」的布尔位**:Trino 有对应机制,Doris 没有,零消费方,三个实现一律传 false。 +4. **投影下推的赋值信息**:Trino 会用它重写计划节点,Doris 引擎只取句柄,连接器被要求构造引擎会丢弃的数据。 +5. **`applyLimit`**:Trino 里是活的协商入口,Doris 有两条 limit 通路而只有另一条接线,且这条的调用时机绕过安全抑制。 +6. **分片形态枚举**:Trino 没有「分片类型」这一层,Doris 自造了封闭枚举、把数据源名字写进公共枚举,还配了一个必须实现却无出口的抽象方法。**这是自造的缺陷**。 +7. **推模型的缓存失效**:Trino 没有这个接口(它的元数据缓存生命周期在连接器内),Doris 自造了推通道,已被拉模型取代且引擎履约不了它的契约。 +8. **能力经字符串表偷运**:Trino 的按表差异走类型化字段,从不用字符串表承载结构化事实。 +9. **在入口接口上做 provider 方法的镜像转发**:Trino 里不存在这种东西——能力永远只在它归属的那个 provider 上。 +10. **函数调用节点的名字字段被三义复用**(真函数名 / 算术运算符号 / 引擎渲染好的整段 Doris 方言 SQL 片段):Trino 的对应节点持有强类型函数名 + 类型,**从不**偷运 SQL 文本;Trino 接口里根本没有「渲染好的 SQL 片段」这个概念。Doris 这个字段目前只靠一个连接器里的一条正则区分三种形态——**这是本轮发现里最纯正的接口设计缺陷**。建议加一个载荷判别枚举(函数 / 算术运算符 / 已渲染 SQL)。 +11. **`ConnectorType` 是字符串 token 而非一等类型系统**:Trino 的类型是接口一等公民(对象而非名字)。这是设计缺陷,但**迁移成本已不可承受**,只补文档是此刻唯一理性的处置。 +12. **服务发现工厂的类型上界泄漏**:Trino 的插件与连接器工厂是两个独立类型、没有继承约束;Doris 的 provider 因继承插件工厂而带了一个必抛异常的无参构造方法。 + +--- + +## 十八、给下一步的三个具体动作 + +1. **先合第 1、2、3 批**(写规则 + 文档据实 + 零破坏分域)。这三批不改变任何运行时行为,但它们直接解决用户提出的「后续新增连接器时能够清晰地实现接口」——今天一个新连接器作者的主要困难不是接口能力不够,而是**没有任何地方告诉他规则是什么,而文档告诉他的有一部分是错的**。 +2. **然后合第 5 批(删类型白名单)**。这是唯一能真正兑现「新增连接器不需要修改公共模块」的一批;在它合入之前,这个承诺在代码上是假的。 +3. **就第 4.5 节(连接器自声明属性)先做决策**:接线成声明式属性,还是删掉那三个死接口。这条卡着「连接器加一个可调参数是否需要改公共模块」这个问题的最终答案,且它牵动用户可见的配置位置,必须由人拍板,不宜由整治批次顺手带过。 + + +--- + +## 附录 A:全部结论清单(172 条) + +按类别分组,每条给出严重度、判定、符号与位置。「判定=部分成立」的条目附上复核后收窄的准确表述。**行号以 `7ff51a106f0` 为准,核对时以符号名为准。** + + +### A.1 新增连接器必须改动公共模块(可扩展性)(17 条) + +**1. fe-core 用硬编码类型白名单 SPI_READY_TYPES 决定谁能走 SPI,新增连接器必须改 fe-core** +- 严重度:中 判定:部分成立 符号:`CatalogFactory.SPI_READY_TYPES / ConnectorProvider#supports` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java:56` +- 复核收窄:CatalogFactory.SPI_READY_TYPES 是编译期硬编码类型白名单,叠在 ConnectorPluginManager 的动态 provider 分派之上,因而任何新连接器(含第三方插件)必须修改 fe-core 才能被路由,且使 ConnectorProvider#supports 的按属性判定能力实际不可用(当前 0 个 override)。 + +**2. FE Config 下发连接器有两条平行通道,且都在 fe-core 里按数据源硬编码 key(hive/trino/sqlserver/maxcompute)** +- 严重度:中 判定:部分成立 符号:`ConnectorContext#getEnvironment / ConnectorSession#getSessionProperties` +- 位置:`fe/fe-connector/fe-connector-spi/.../ConnectorContext.java:44` +- 复核收窄:引擎配置有两条下发通道且分工只存在于注释、未写进 SPI 契约:ConnectorContext#getEnvironment(catalog 生命周期的 FE 静态 Config,fe-core 逐源硬编码 9 个 key,SPI javadoc 只文档化 2 个)与 ConnectorSession#getSessionProperties(会话变量快照)。 + +**3. getSyntheticScanPredicates 声称"引擎照单全收",实际 fe-core 反向转换器只认 And+5 种比较+STRING 字面量,其余直接抛异常** +- 严重度:中 判定:部分成立 符号:`ConnectorMetadata#getSyntheticScanPredicates / ConnectorExpressionToNereidsConverter` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:177` +- 复核收窄:准确表述:真问题是**公共 API 文档缺失/误导**,不是可扩展性硬缺陷。 + +**4. 新连接器支持 CREATE TABLE 需要在 fe-core CreateTableInfo 里做四处协同改动(engine 常量、type→engine switch、engine 白名单、分区/分桶允许列表)** +- 严重度:中 判定:部分成立 符号:`CreateTableInfo#pluginCatalogTypeToEngine / #checkEngineName / #analyzeEngine` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java:121` +- 复核收窄:CREATE TABLE 支持面确实由 fe-core CreateTableInfo 的 engine 名硬编码把关(3 个协同编辑点:ENGINE_* 常量+pluginCatalogTypeToEngine switch、checkEngineName 白名单、analyzeEngine 分区/分桶允许列表),新 create-table 连接器必须改 fe-core。 + +**5. fe-core 事件驱动里硬编码 "hms" 类型串,新增带事件源的连接器必须改 fe-core** +- 严重度:中 判定:部分成立 符号:`MetastoreEventSyncDriver#realRun` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java:119` +- 复核收窄:准确表述:MetastoreEventSyncDriver 只在“catalog 尚未 initialized 时是否强制预热以取得事件源”这一条 legacy-parity 分支上硬编码了 "hms" 类型串(:119,被 !isInitialized() 一次性守卫);轮询本身已是中立能力探针(:133-140 getEventSource()!=null)。 + +**6. fe-core 按 catalog 类型字符串 switch 决定表的 engine / tableType 展示名,新增连接器必须改 fe-core** +- 严重度:中 判定:部分成立 符号:`PluginDrivenExternalTable#getEngine / #getEngineTableTypeName` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java:1267` +- 复核收窄:准确表述:PluginDrivenExternalTable 用 catalog 类型字符串的封闭 switch(getEngine:1275-1305 与 getEngineTableTypeName:1313-1330,同一张 7 项白名单重复两份)决定对外 engine / tableType 展示名,SPI 无任何声明位,因此新连接器若想显示自己的 engine 名必须改 fe-core。 + +**7. fe-core 用硬编码类型白名单决定是否走 SPI,新连接器即使注册了 ConnectorProvider 也会被拒** +- 严重度:低 判定:部分成立 符号:`CatalogFactory#SPI_READY_TYPES` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java:56` +- 复核收窄:CatalogFactory.java:56-57 的 SPI_READY_TYPES 硬编码白名单确实使未列名类型即便注册了 ConnectorProvider(META-INF/services)也无法 CREATE CATALOG(最终落到 :157 的「Unknown catalog type」),与 ConnectorProvider:46 getType()/:52 supports() 的自描述发现机制口径矛盾,fe-core 也仍留 4 处按源名字符串的分支(… + +**8. ConnectorCapability 是封闭枚举:连接器要新增任何一种可选能力都必须先改公共 api 模块** +- 严重度:低 判定:部分成立 符号:`ConnectorCapability` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java:98` +- 复核收窄:ConnectorCapability 作为封闭枚举本身不是可扩展性缺陷(能力位定义即"引擎必须消费的开关",无消费方的能力无意义,新增必然涉及 fe-core)。 + +**9. ConnectorExpression 无 accept/无节点判别符,公共接口对外开放但事实封闭:任何新节点类型都要改 fe-core 两个转换器与每个连接器的 instanceof 链** +- 严重度:低 判定:部分成立 符号:`ConnectorExpression#getChildren` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorExpression.java:31` +- 复核收窄:ConnectorExpression 缺少权威节点清单与“未识别节点必须安全降级”的契约声明,消费方全靠 136 处 instanceof 逆向摸索;但这不构成判据 2 的可扩展性违规——新增连接器无需修改任何公共模块,新增节点类型在任何设计(含 visitor)下都要改 api,且窄反向语法+抛异常是文件注释里说明过的 fail-loud 有意取舍。 + +**10. 连接器残余谓词回译只支持 AND/比较/STRING 字面量,更宽的表达式必须改 fe-core 转换器** +- 严重度:低 判定:部分成立 符号:`ConnectorExpressionToNereidsConverter#convert` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorExpressionToNereidsConverter.java:77` +- 复核收窄:反向残余谓词转换器刻意只覆盖 AND + EQ/LT/LE/GT/GE + STRING 字面量并 fail-loud(类注释 :53-62 自述),这不是读/写对称性缺口而是有意的最小语法面;真实残留仅为扩展点:若将来某连接器的合成扫描谓词需要 OR/IN/数值或日期字面量,必须先改 fe-core 转换器并补 ConnectorType→DataType 映射,届时再扩展比现在预建更符合无冗余原则。 + +**11. ConnectorMvccPartitionView.Style 只有 UNPARTITIONED/RANGE,无法表达 LIST+snapshot-id;且 UNPARTITIONED 被复用成"MTMV 不合格"裁决位,fe-core 还会在它背后自行枚举 LIST 分区** +- 严重度:低 判定:部分成立 符号:`ConnectorMvccPartitionView.Style` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java:51` +- 复核收窄:真实缺陷只是文档漂移+语义复用:Style.UNPARTITIONED 的 javadoc(:53-55) 声称"generic model treats it as unpartitioned",但 fe-core:183-189 在该裁决背后仍走 listPartitions 枚举 LIST 分区(只有 MTMV 合格性/partitionType 按 UNPARTITIONED 处理),应把该句改为"连接器裁定本表不适用该 range view(含 MTMV 不合格); + +**12. 命名空间“按构造唯一”依赖 getType() 唯一,但内建 provider 注册不做重名检查,也没有优先级契约** +- 严重度:低 判定:成立 符号:`ConnectorProvider#getType / ConnectorPluginManager#loadBuiltins/registerProvider` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java:74` + +**13. ConnectorScanProfile 名义中立,但 group 名字必须匹配 fe-core SummaryProfile 里硬编码的 per-connector 常量表,新连接器要出 scan profile 就得改 fe-core** +- 严重度:低 判定:部分成立 符号:`ConnectorScanProfile#getGroupName` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java:158` +- 复核收窄:fe-core SummaryProfile.java:158-159/218-219/278-279 确实残留 Iceberg/Paimon 两个源专有字面量(中立性小瑕疵,且与连接器侧字符串双份互钉),应删除。 + +**14. 连接器的 FE 全局配置项 / session 变量必须加在 fe-common Config、fe-core SessionVariable,并在 fe-core 里逐 key 手工转发** +- 严重度:低 判定:部分成立 符号:`DefaultConnectorContext#buildEnvironment / ConnectorSessionBuilder#extractSessionProperties` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java:568` +- 复核收窄:只有当连接器需要 **FE 进程级全局配置** 或 **可 SET 的 session 变量** 时,才必须改 fe-common Config / fe-core SessionVariable 并在 DefaultConnectorContext.buildEnvironment 手工加一行转发(键靠注释要求 byte-identical,无编译期约束);per-catalog 属性这条主通道是连接器自助的、零公共模块改动。 + +**15. FileQueryScanNode.CACHEABLE_CATALOGS 按 catalog 类型白名单决定是否做 file-cache 准入治理,新连接器静默绕过** +- 严重度:低 判定:部分成立 符号:`FileQueryScanNode#CACHEABLE_CATALOGS` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java:119` +- 复核收窄:该判断用 catalog 类型字符串白名单而非“是否走 BE 原生文件读取”的能力位,是上游 #59065 既有的 fe-core 代码;当前所有连接器都被正确覆盖(不在名单里的 jdbc/trino/max_compute 走 JNI、本无 file cache),且跳过路径有 LOG.debug,故不是现存 bug;真实残留只是扩展点:未来新增一个 BE 原生读文件的湖格式连接器必须改 fe-core 才能纳入准入治理。 + +**16. WriteBlockAllocatingConnectorTransaction 名字中立但唯一入口是 maxcompute 专有 thrift RPC,且 fe-core 里存在同签名孪生接口(一套能力两处声明)** +- 严重度:低 判定:部分成立 符号:`WriteBlockAllocatingConnectorTransaction#allocateWriteBlockRange` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/WriteBlockAllocatingConnectorTransaction.java:29` +- 复核收窄:api 侧 WriteBlockAllocatingConnectorTransaction 是合规的窄 opt-in 能力位(javadoc 已注明 only maxcompute today),真正的源专有耦合在**传输层**:BE→FE 通道是 maxcompute 专名 thrift(TMaxComputeBlockIdRequest/Result)+ FrontendServiceImpl.getMaxComputeBlockIdRange,第二个连接器要复用必须改… + +**17. 连接器模块名在 pom 与 build.sh 里被硬编码三处,新增连接器必须改共享构建脚本才能被编译与部署** +- 严重度:低 判定:部分成立 符号:`build.sh 连接器模块循环 / fe-connector/pom.xml ` +- 位置:`fe/fe-connector/pom.xml:40` +- 复核收窄:新增连接器需同步 3 处构建清单(fe/fe-connector/pom.xml:40-67 + build.sh:729 编译循环 + build.sh:1073 部署循环),其中 pom 无法避免、两处 shell 名单可改为目录 glob 发现(tools/check-connector-imports.sh 已有先例); + + +### A.2 公共模块里的数据源专有语义(中立性)(25 条) + +**18. 通用 PluginDrivenScanNode 里仍有 ES 专有分支(EXPLAIN terminate_after + esProperties limit),而 SPI 已有 appendExplainInfo/populateScanLevelParams 可承接** +- 严重度:高 判定:成立 符号:`PluginDrivenScanNode#getNodeExplainString / #createScanRangeLocations` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java:559` + +**19. executeRestRequest 是 ES 专用逃生门:唯一实现方是 ES,唯一调用方是按 type=="es" 硬判的 REST 接口** +- 严重度:中 判定:成立 符号:`Connector#executeRestRequest` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:303` + +**20. applySnapshot 的公共契约把 paimon 专有的 scan.snapshot-id 选项键写成了中立 SPI 的回退规则,api 自带单测也硬编码了该键** +- 严重度:中 判定:成立 符号:`ConnectorMetadata#applySnapshot` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:159` + +**21. 分区身份以 hive 目录名文法 k=v/k=v 为准,且 javadoc 承诺的“fe-core 回退解析分区名”在引擎侧已被删除** +- 严重度:中 判定:部分成立 符号:`ConnectorPartitionInfo#partitionName / #orderedPartitionValues / #partitionValueNullFlags` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java:57` +- 复核收窄:真实缺陷是文档-实现不一致(severity medium):ConnectorPartitionInfo.java:57 与 :185-187 承诺“不填则 fe-core 回退解析 partitionName”,而 PluginDrivenMvccExternalTable.java:333-336 已删除回退、改为 arity 硬校验,未填的新连接器会被静默降级为 UNPARTITIONED(有 LOG.warn,但按 javadoc 写代码的人不会预期)。 + +**22. 公共 API 唯一的源专有字符串常量 __HIVE_DEFAULT_PARTITION__,且中立命名的 normalize()/isNullPartitionValue() 只有 hive 语义成立** +- 严重度:中 判定:成立 符号:`ConnectorPartitionValues#HIVE_DEFAULT_PARTITION / #normalize / #isNullPartitionValue` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java:26` + +**23. getSerializedTable(Map) 是给 paimon 开的后门:名字通用、语义未定义,实现只是把自己写进 props 的 paimon.serialized_table 原样取回** +- 严重度:中 判定:部分成立 符号:`ConnectorScanPlanProvider#getSerializedTable` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:520` +- 复核收窄:准确表述:这不是为 paimon 新开的源专有后门(它承接的是既有通用钩子 FileQueryScanNode.getSerializedTable + 通用 thrift 字段 serialized_table)。 + +**24. ConnectorScanRangeType 是含 JDBC_SCAN 源名的封闭枚举,但引擎从不读它,8 个连接器(含 jdbc 自己)全返回 FILE_SCAN** +- 严重度:中 判定:成立 符号:`ConnectorScanRangeType#JDBC_SCAN` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRangeType.java:40` + +**25. requestBeConnectivityTest 名义中立、实质只能是 JDBC:byte[] 里偷运 TJdbcTable、int 是 TOdbcTableType 序号** +- 严重度:中 判定:部分成立 符号:`ConnectorValidationContext#requestBeConnectivityTest` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorValidationContext.java:61` +- 复核收窄:准确表述:requestBeConnectivityTest 的签名在 fe-connector-api 里是中立的,但 (a) javadoc 用 TTableDescriptor/TOdbcTableType 举例泄漏 thrift 类型、(b) byte[] payload 的编码与 int 的取值空间没有任何契约、(c) 唯一的引擎实现(fe-core executePendingBeTests)只会把它发成 PJdbcTestConnectionRequest→te… + +**26. ConnectorWritePartitionField 是 iceberg 分区规格换了个中立名字:transform 是 iceberg 的 toString 文本,fe-core 直接字符串比对 "identity",sourceId 字段 javadoc 自称 "iceberg source field id"** +- 严重度:中 判定:成立 符号:`ConnectorWritePartitionField#getTransform / #getSourceId` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionField.java:29` + +**27. DISTRIBUTED 过程的结果 schema 被硬编码成 iceberg rewrite_data_files 的四列,新连接器要加分布式过程必须改 fe-core** +- 严重度:中 判定:部分成立 符号:`ProcedureExecutionMode.DISTRIBUTED / ConnectorRewriteGroup` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorRewriteDriver.java:245` +- 复核收窄:准确表述:DISTRIBUTED 过程的**结果 schema 所有权**错放在 fe-core——ConnectorRewriteDriver.buildResult 硬编码 iceberg rewrite_data_files 的四列名/类型(含刻意保留的 rewritten_bytes_count=INT 遗留 quirk),与 SINGLE_CALL 由连接器返回 ConnectorProcedureResult 不对称; + +**28. BranchChange/TagChange 的字段集是 Iceberg SnapshotRef 的 1:1 映射(含 long snapshotId 与三个保留期旋钮),却挂在所有连接器共享的 ConnectorTableOps 上** +- 严重度:低 判定:部分成立 符号:`BranchChange / TagChange / ConnectorTableOps#createOrReplaceBranch` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/BranchChange.java:37` +- 复核收窄:BranchChange/TagChange 的字段命名沿用 snapshot/SnapshotRef 词汇,是中立模块里的 iceberg 词汇残留(文档/命名层面); + +**29. BranchChange/TagChange/DropRefChange 把 iceberg 的 snapshot-ref 模型(snapshotId + 三个 ref 保留期旋钮)直接放进中立 ddl 包,只有 iceberg 能用** +- 严重度:低 判定:部分成立 符号:`BranchChange / TagChange / DropRefChange` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/BranchChange.java:31` +- 复核收窄:三个 DTO 的**字段命名**确实借用了 iceberg SDK 的旋钮名(maxSnapshotAgeMs/minSnapshotsToKeep/maxRefAgeMs),而上游中立来源 BranchOptions 用的是 SQL 级中立名 retain/numSnapshots/retention——这是可修正的命名泄漏; + +**30. getEventSource 的 javadoc 承诺引擎完全 connector-agnostic,但驱动器用硬编码 "hms" 类型串筛选,新连接器的事件源永远不会被激活** +- 严重度:低 判定:部分成立 符号:`Connector#getEventSource` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:347` +- 复核收窄:事实成立但范围要收窄:MetastoreEventSyncDriver.java:119 用硬编码 "hms" 类型串筛选,位置在 `!isInitialized()` 的强制预初始化分支内,因此影响面是“本 FE 上从未初始化过的非 hms 事件源目录不会被强制预初始化、其游标不会自动 seed(FE 重启后需该目录被查询一次才开始同步)”,而非“事件源永远不会被激活”——已初始化目录走 :132 的完全中立探测。 + +**31. SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE 的语义被定义为“Iceberg 风格的 schema-change 子句集”,是中立名字承载单源定义的能力位** +- 严重度:低 判定:部分成立 符号:`ConnectorCapability#SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorCapability.java:166` +- 复核收窄:该能力位的语义在 javadoc 首段已按 op 显式枚举(ADD/DROP/RENAME/MODIFY COLUMN 含嵌套点路径 + MODIFY COLUMN COMMENT),不是「由 iceberg 定义」;引擎侧也无源分支(per-table 反射能力集)。 + +**32. 中立的 ConnectorContext 上挂着 jdbc 专有安全钩子 sanitizeJdbcUrl,且“必须调用”的契约只有 jdbc 连接器遵守** +- 严重度:低 判定:部分成立 符号:`ConnectorContext#sanitizeJdbcUrl` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java:79` +- 复核收窄:中立性部分成立:公共 SPI ConnectorContext 上有协议命名的 sanitizeJdbcUrl(唯一真实调用者只有 fe-connector-jdbc),javadoc 用 MUST 定了一条无强制点的安全契约,建议改名为 sanitizeRemoteUrl/validateOutboundEndpoint。 + +**33. ConnectorDelegatedCredential 的 javadoc/枚举把 Iceberg REST OAuth2 语义写进中立 API,且 Type 与 fe-core 枚举靠 valueOf 名字耦合** +- 严重度:低 判定:部分成立 符号:`ConnectorDelegatedCredential.Type` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorDelegatedCredential.java:85` +- 复核收窄:应收窄为两点:(a) 原则 1 轻度违反——ConnectorDelegatedCredential.Type 的 javadoc(:85-89) 把 Iceberg OAuth2 token-type/token_exchange 映射写进中立 API 文档,应下沉到 IcebergDelegatedCredentialUtils; + +**34. event 包(MetastoreChangeDescriptor / ConnectorEventSource)是 HMS 通知日志模型的中立包装:单调递增 long eventId 游标,唯一实现者是 HMS** +- 严重度:低 判定:部分成立 符号:`ConnectorEventSource#getCurrentEventId / MetastoreChangeDescriptor` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/ConnectorEventSource.java:42` +- 复核收窄:event 包的变更词汇与类型中立性是真中立; + +**35. 中立 API 里放了 hive 品牌的分区哨兵常量 __HIVE_DEFAULT_PARTITION__,且与 fe-core 的同名常量重复定义** +- 严重度:低 判定:部分成立 符号:`ConnectorPartitionValues#HIVE_DEFAULT_PARTITION` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java:26` +- 复核收窄:准确表述:`ConnectorPartitionValues` 存在两个较轻的问题——(a) 命名与语义带 Hive 品牌(`HIVE_DEFAULT_PARTITION`)却放在中立 API,且类无 javadoc,未写清 `\N`/哨兵的来源与适用范围;(b) 同一字面量在 fe-core `TablePartitionValues.java:47` 另有一份活定义,形成跨模块重复。 + +**36. 中立 API 里硬编码 hive 的 __HIVE_DEFAULT_PARTITION__ / \\N 作为"分区空值"的通用语义** +- 严重度:低 判定:部分成立 符号:`ConnectorPartitionValues.HIVE_DEFAULT_PARTITION / isNullPartitionValue / normalizePartitionValue` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java:26` +- 复核收窄:准确表述:中立模块 fe-connector-api 里放了 hive 血统的哨兵常量 HIVE_DEFAULT_PARTITION/"\\N" 及带 hive 语义、名字却通用的 isNullPartitionValue/normalizePartitionValue(ConnectorPartitionValues.java:26-54),fe-core 的 hive-布局路径解析回退分支(FilePartitionUtils.java:143)也直接引用该常量——属命名… + +**37. isNativeReadRange() 名字中立,实际存在的唯一目的是拼 paimon 的 EXPLAIN 行,且只有 paimon 实现** +- 严重度:低 判定:部分成立 符号:`ConnectorScanRange#isNativeReadRange` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:158` +- 复核收窄:该方法的规范语义是中立且明确的(BE 原生 ORC/Parquet reader vs JNI),引擎累加也中立;唯一问题是 javadoc 把 paimon 专有的 EXPLAIN 键名 `paimonNativeReadSplits` 写进了中立 API 的说明文字(且 paimon 是当前唯一实现)。 + +**38. 中立 API/SPI 的语义契约用数据源专有术语定义(Iceberg OAuth2 会话、paimon SDK、iceberg 目录布局、HMS 通知)** +- 严重度:低 判定:部分成立 符号:`ConnectorSession#getSessionId / ConnectorContext#vendStorageCredentials / #cleanupEmptyManagedLocation` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java:33` +- 复核收窄:这四处在代码层面(符号/常量/类型/分支)均中立,属 javadoc 表述问题而非中立性违规:getSessionId 与 vendStorageCredentials 的说明把 Iceberg OAuth2 缓存键、paimon SDK 取 token 写成唯一动机/唯一输入来源,宜改为“中立契约在前、当前使用者举例在后”; + +**39. ConnectorTableSchema 上还有第二个同名 getTableFormatType(),取值是大写源名,且零生产消费者** +- 严重度:低 判定:成立 符号:`ConnectorTableSchema#getTableFormatType` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java:125` + +**40. MetastoreChangeDescriptor / EventPoll* 把 HMS 通知日志模型(单调 long event id + Hive 语义)当作中立词汇** +- 严重度:低 判定:部分成立 符号:`MetastoreChangeDescriptor` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/MetastoreChangeDescriptor.java:47` +- 复核收窄:应收窄:MetastoreChangeDescriptor 本身字段与 Op 枚举是中立的(纯 String/long/List,Op 表达的是引擎动作而非 Hive 事件类型),不构成“HMS 模型当中立词汇”。 + +**41. 通用 PluginDrivenScanNode 里残留 es_http 源专属 EXPLAIN 分支,且未知 format 静默回落 FORMAT_JNI** +- 严重度:低 判定:部分成立 符号:`PluginDrivenScanNode#getNodeExplainString / #mapFileFormatType` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java:559` +- 复核收窄:通用 PluginDrivenScanNode 在 :559-565 保留了只有 es 会触发的 EXPLAIN 分支(键 es_http,虽是 thrift 格式值而非源名),与同文件 :494-499 自述规则和 :545 的 appendExplainInfo 委派矛盾,应搬进已实现该钩子的 EsScanPlanProvider; + +**42. RewriteCapableTransaction 是 iceberg rewrite_data_files 的通用化包装:方法直接对应该过程的输入/输出列,两个方法职责相反,且不 extends ConnectorTransaction 使 instanceof 无类型保障** +- 严重度:低 判定:部分成立 符号:`RewriteCapableTransaction#registerRewriteSourceFiles / #getRewriteAddedDataFilesCount` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/RewriteCapableTransaction.java:22` +- 复核收窄:RewriteCapableTransaction 作为 opt-in 混入能力位的设计本身正当(刻意不污染 ConnectorTransaction 主契约、以中立 String 路径跨墙、唯一实现 iceberg),不构成中立性违规;可挑的只是 javadoc 直接把自己钉在 iceberg 的 rewrite_data_files 与其结果列 added_data_files_count 上(宜改述为“按文件路径原子替换的 compaction 模型”)。 + + +### A.3 没有调用方或没有实现方的接口面(死接口)(33 条) + +**43. Connector#getTableProperties / getSessionProperties 与 ConnectorPropertyMetadata 零实现零调用(本应是属性自声明机制)** +- 严重度:中 判定:成立 符号:`Connector#getSessionProperties / ConnectorPropertyMetadata` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:235` + +**44. ConnectorEventSource#getCurrentEventId 零生产调用方,javadoc 声称引擎会用它做"先探后拉"** +- 严重度:中 判定:成立 符号:`ConnectorEventSource#getCurrentEventId` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/ConnectorEventSource.java:44` + +**45. ConnectorPartitionValueDef 整个类 + getInitialValues() + 三参构造在生产链路上零生产者零消费者,永远是空列表** +- 严重度:中 判定:成立 符号:`ConnectorPartitionValueDef / ConnectorPartitionSpec#getInitialValues` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionValueDef.java:33` + +**46. getSupportedProcedures() 是 ConnectorProcedureOps 唯一必须实现的方法,却在生产链路上零消费者,javadoc 宣称的「routing、validation」两项引擎都没做** +- 严重度:中 判定:部分成立 符号:`ConnectorProcedureOps#getSupportedProcedures` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java:48` +- 复核收窄:getSupportedProcedures()(ConnectorProcedureOps.java:52)是接口的两个抽象方法之一(另一个是 :117 execute),其 javadoc 宣称引擎用于 routing/validation 与实际不符:路由走 getExecutionMode(ConnectorExecuteAction.java:142)、未知名拒绝由连接器在 execute 内完成(:228-232),而唯一读取点 ExecuteActionFact… + +**47. ConnectorPropertyMetadata 与 Connector#getTableProperties/getSessionProperties 全仓零消费、零实现** +- 严重度:中 判定:成立 符号:`ConnectorPropertyMetadata / Connector#getTableProperties / Connector#getSessionProperties` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPropertyMetadata.java:27` + +**48. applyLimit / LimitApplicationResult 零实现零构造,是完全死的 SPI 面;且它的调用时机绕过了 CAST-strip 的安全抑制** +- 严重度:中 判定:成立 符号:`ConnectorPushdownOps#applyLimit / LimitApplicationResult` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPushdownOps.java:53` + +**49. ConnectorScanRangeType 全枚举无引擎消费者,但 getRangeType() 是强制方法,每个新连接器都得实现一个没人读的返回值** +- 严重度:中 判定:成立 符号:`ConnectorScanRange#getRangeType / ConnectorScanRangeType` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRangeType.java:34` + +**50. ConnectorScanRange#getRangeType 是 8 个连接器必须实现的抽象方法,但其值只被 api 自己的默认方法塞进一个全仓库无人读取的 key** +- 严重度:中 判定:部分成立 符号:`ConnectorScanRange#getRangeType / ConnectorScanRangeType / ConnectorScanPlanProvider#getScanRangeType` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:43` +- 复核收窄:证据全部成立(零生产消费者的必填抽象方法 + 3 个零引用枚举常量 + 零调用方的 getScanRangeType + 失真 javadoc),建议的删除方案可行且无兼容障碍;只是严重性应为 medium 而非 high——现象是 API 冗余与文档误导("改了枚举为什么不生效"式排查浪费),没有任何正确性或性能后果,唯一运行时痕迹是 JdbcScanRange 经默认 populateRangeParams 写入一个 BE 不读的 jdbc_params key。 + +**51. ConnectorScanRangeType 是封闭枚举,4 个值里 3 个零生产者,且引擎从不读 getRangeType()/getScanRangeType()** +- 严重度:中 判定:部分成立 符号:`ConnectorScanRangeType / ConnectorScanRange#getRangeType / ConnectorScanPlanProvider#getScanRangeType` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRangeType.java:34` +- 复核收窄:ConnectorScanRangeType 是零分发的死表面:4 个值里只有 FILE_SCAN 有生产者(22 命中),另 3 值零命中;引擎 main 侧从不读 getRangeType()/getScanRangeType()(0 命中),唯一消费是 API 默认 populateRangeParams 写入的 `connector_scan_range_type` 键,BE 也不识别(be/src 0 命中)。 + +**52. listPartitionValues 零生产调用方,javadoc 宣称的 partition_values() TVF 实际走的是 listPartitionNames** +- 严重度:中 判定:成立 符号:`ConnectorTableOps#listPartitionValues` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:499` + +**53. listPartitions 的 Optional filter 无任何非空调用方,且没有一个连接器按 javadoc 要求真正应用它** +- 严重度:中 判定:部分成立 符号:`ConnectorTableOps#listPartitions(session, handle, filter)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:487` +- 复核收窄:listPartitions 的 Optional filter 在生产中零非空调用方(fe-core 4 处全传 Optional.empty()),且无任何连接器按 javadoc 的“建议下推”真正过滤;真实缺陷是公共 javadoc 未声明“返回值可能未过滤”,而分区裁剪本由 applyFilter 承担——因此该参数属冗余表面(判据 3),只是三个连接器把它当缓存旁路开关,未来若有调用方传 filter 会静默拿到全量分区。 + +**54. ProjectionApplicationResult 的 projections/assignments(及 ConnectorColumnAssignment 整个类)只有 trino 在生产、引擎从不读取** +- 严重度:中 判定:成立 符号:`ProjectionApplicationResult#getProjections / #getAssignments / ConnectorColumnAssignment` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ProjectionApplicationResult.java:32` + +**55. ConnectorPropertyMetadata 及 Connector 的两个属性描述符方法零实现零调用,且方法名与 ConnectorSession#getSessionProperties 撞名异义** +- 严重度:低 判定:成立 符号:`Connector#getTableProperties / Connector#getSessionProperties / ConnectorPropertyMetadata` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:235` + +**56. ConnectorContractValidator 无生产调用方,且只被 4/8 个连接器测试主动调用——真正声明 hash 写的 hive 恰好没调** +- 严重度:低 判定:部分成立 符号:`ConnectorContractValidator#validate` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java:29` +- 复核收窄:准确表述:ConnectorContractValidator 不是死代码,而是被有意限定在契约测试里调用的静态断言工具(不在 catalog 注册期跑有客观理由:读写能力会构造 write plan provider,iceberg 会 eager 建远端 catalog)。 + +**57. ConnectorCreateTableRequest.isExternal() 零消费者,没有任何连接器读取** +- 严重度:低 判定:成立 符号:`ConnectorCreateTableRequest#isExternal` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java:115` + +**58. ConnectorHttpSecurityHook 只有 ES 一个连接器履约,其余走 HTTP 的连接器完全绕过,无任何强制** +- 严重度:低 判定:部分成立 符号:`ConnectorHttpSecurityHook` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorHttpSecurityHook.java:38` +- 复核收窄:准确表述:ConnectorHttpSecurityHook 不是死面(引擎 DefaultConnectorContext.java:107-117 有真实实现,ES 有真实调用),也不是源专有概念; + +**59. ConnectorLiteral 的 6 个 of* 工厂方法无生产调用方(3 个连测试都没有),生产代码统一走构造器** +- 严重度:低 判定:部分成立 符号:`ConnectorLiteral#ofInt/ofLong/ofDouble/ofDecimal/ofDate/ofDatetime` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLiteral.java:61` +- 复核收窄:6 个工厂中只有 3 个是真死代码:ofDouble/ofDecimal/ofDate 在全仓(含测试)零引用,可直接删;ofInt/ofLong/ofDatetime 是生产零调用、但被 4-5 个连接器测试(合计 60+ 处)当作便捷构造入口的测试专用工厂,并非误导性 API;“生产必须绕开 of* 因为它臆断类型”只适用于 fe-core ExprToConnectorExpressionConverter 的类型保真路径,不是所有生产场景(ofString/ofNul… + +**60. ConnectorMetadata#getProperties 无任何调用方,却已被 5 个连接器覆盖实现** +- 严重度:低 判定:成立 符号:`ConnectorMetadata#getProperties` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:54` + +**61. ConnectorMvccPartitionView.Freshness.LAST_MODIFIED 零生产方零消费方,与 ConnectorMvccSnapshot#isLastModifiedFreshness 是两套并存的同一机制** +- 严重度:低 判定:部分成立 符号:`ConnectorMvccPartitionView.Freshness#LAST_MODIFIED` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java:65` +- 复核收窄:ConnectorMvccPartitionView.Freshness.LAST_MODIFIED 目前零生产方(唯一 view 生产方 iceberg 恒发 SNAPSHOT_ID),是一个尚无实现方的扩展位; + +**62. ConnectorMvccSnapshot 的 description / timestampMillis 两个字段(含 builder setter)零生产方零消费方,只有 api 自带单测在用** +- 严重度:低 判定:部分成立 符号:`ConnectorMvccSnapshot#getDescription / #getTimestampMillis` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java:59` +- 复核收窄:ConnectorMvccSnapshot#getDescription / #getTimestampMillis(含 builder setter、equals/hashCode/toString 项)在生产代码零生产方零消费方,仅 api 自带单测与 IcebergConnectorMetadataTest 使用,属可安全清理的死面(该类无 Gson/thrift 持久化约束)。 + +**63. fe-core 的 ConnectorMvccSnapshotAdapter 零调用方,与 PluginDrivenMvccSnapshot 是同一职责的两套并存包装** +- 严重度:低 判定:成立 符号:`ConnectorMvccSnapshotAdapter` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorMvccSnapshotAdapter.java:32` + +**64. ConnectorPartitionHandle 全仓零引用(既无实现方也无调用方)** +- 严重度:低 判定:成立 符号:`ConnectorPartitionHandle` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorPartitionHandle.java:25` + +**65. ConnectorPartitionValues 的两个 public static 助手只有类内调用方** +- 严重度:低 判定:成立 符号:`ConnectorPartitionValues#isNullPartitionValue / #normalizePartitionValue` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java:46` + +**66. execute() 的 whereCondition 参数在生产上恒为 null,javadoc 却暗示可能非空** +- 严重度:低 判定:成立 符号:`ConnectorProcedureOps#execute(whereCondition)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java:111` + +**67. estimateScanRangeCount 零生产调用方(只有 jdbc 实现了它,引擎从不调用)** +- 严重度:低 判定:成立 符号:`ConnectorScanPlanProvider#estimateScanRangeCount` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:432` + +**68. createTable 与 dropDatabase 的"legacy"重载零实现零调用,其降级默认还会静默丢弃 DDL 语义** +- 严重度:低 判定:成立 符号:`ConnectorTableOps#createTable(ConnectorSession, ConnectorTableSchema, Map) / ConnectorSchemaOps#dropDatabase(ConnectorSession, String, boolean)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:223` + +**69. ConnectorTableSchema.tableFormatType 无人读取、可为 null 无文档,且各连接器填的语义互不相同** +- 严重度:低 判定:成立 符号:`ConnectorTableSchema#getTableFormatType` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java:125` + +**70. ConnectorTestResult 的 sub-component 机制(withComponents/getComponentResults)零调用,且 equals 忽略该字段** +- 严重度:低 判定:成立 符号:`ConnectorTestResult#withComponents / #getComponentResults` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTestResult.java:63` + +**71. ConnectorTransactionHandle 是零生产者零消费者的空标记接口,ConnectorTransaction 对它的"兼容既有 API"说法在代码里不成立** +- 严重度:低 判定:成立 符号:`ConnectorTransactionHandle` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransactionHandle.java:23` + +**72. ConnectorType 的 4 个 children 列表 getter 零调用方(实际都走按索引访问器)** +- 严重度:低 判定:成立 符号:`ConnectorType#getChildrenNullable/#getChildrenComments/#getChildrenFieldIds/#getChildrenCommentSpecified` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java:250` + +**73. ConnectorViewDefinition 强制非 null 的 dialect 从未被引擎读取,实际按 session 方言解析视图体** +- 严重度:低 判定:部分成立 符号:`ConnectorViewDefinition#getDialect` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorViewDefinition.java:41` +- 复核收窄:ConnectorViewDefinition.dialect 被 requireNonNull 强制必填,但引擎零读取(视图体按 session 方言经 SqlDialectHelper 转换),且类 javadoc 暗示引擎会按该方言解析——文档与行为不一致,取值空间也未定义(hive 只能编造占位符 "hive")。 + +**74. precalculateStatistics 在两个 ApplicationResult 上是必填构造参数,但引擎从不读取,三个实现一律传 false** +- 严重度:低 判定:成立 符号:`FilterApplicationResult#isPrecalculateStatistics / LimitApplicationResult#isPrecalculateStatistics` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/FilterApplicationResult.java:31` + +**75. fe-core MetastoreProperties.Type 仍保留 hms/iceberg/paimon 等源常量但工厂已不注册,形成误导性的注册点残留** +- 严重度:低 判定:部分成立 符号:`MetastoreProperties.Type / FACTORY_MAP` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/MetastoreProperties.java:48` +- 复核收窄:准确表述:fe-core 内部类 MetastoreProperties 的 Type 枚举现只有 TRINO_CONNECTOR 一个常量仍注册工厂,其余 8 个(HMS/ICEBERG/PAIMON/GLUE/DLF/DATAPROC/FILE_SYSTEM/UNKNOWN)均已失效并只在 create() 处 fail-loud; + + +### A.4 同一件事有多套并存机制(冗余)(30 条) + +**76. 「声明一项可选能力」在公共 API 里同时存在 8 套并存机制,无统一规则,新连接器无法预判该用哪一套** +- 严重度:中 判定:部分成立 符号:`Connector#getCapabilities / ConnectorCapability / Connector#supportsWriteBranch 等` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:211` +- 复核收窄:公共 API 里「声明可选能力」确有多条并行通道且无任何放置规则文档(fe-connector-api 无 package-info.java),但真正违反「无冗余/语义清晰」的只有三点:①同一批 ConnectorCapability 枚举值同时经连接器级 Set 与表级 CSV 字符串两条异质通道消费; + +**77. 能力声明有两套并存机制:类型化 Set 与藏在属性 Map 里的 CSV,且只有 5/13 个能力支持后者** +- 严重度:中 判定:部分成立 符号:`Connector#getCapabilities() vs ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java:98` +- 复核收窄:准确表述:per-table 能力细化以 CSV 字符串藏在 ConnectorTableSchema 的属性 Map 里(PER_TABLE_CAPABILITIES_KEY),而连接器级用类型化 Set;两者是同一 enum 的两个作用域(fe-core 做加法),不是两套竞争机制,且 key 契约有完整文档并集中登记。 + +**78. 缓存失效有两套并存机制:SPI 的 ConnectorMetaInvalidator(零生产调用)与 api 的 Connector.invalidate*,且分区身份模型互相冲突** +- 严重度:中 判定:部分成立 符号:`ConnectorMetaInvalidator vs Connector#invalidateTable/invalidateDb/invalidateAll/invalidatePartition` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java:32` +- 复核收窄:准确表述:fe-connector-spi 的 ConnectorMetaInvalidator 及其入口 ConnectorContext.getMetaInvalidator 与 fe-core 实现 ExternalMetaCacheInvalidator 构成零生产调用方的死 SPI 表面(iceberg 测试注释证明连接器侧通知曾被有意移除,失效改由引擎经 ConnectorEventSource/MetastoreChangeDescriptor 驱动 Conne… + +**79. 失效(invalidate)存在两套方向相反的词汇:api 那套是活的,spi 的 ConnectorMetaInvalidator 零连接器调用且引擎侧无法履约** +- 严重度:中 判定:成立 符号:`ConnectorMetaInvalidator vs Connector#invalidateTable/invalidateDb/invalidatePartition` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java:32` + +**80. ConnectorPartitionInfo 用三套并存表达同一份分区值(name 字符串 / Map / 有序 List),并叠了 6 个望远镜构造器** +- 严重度:中 判定:部分成立 符号:`ConnectorPartitionInfo#getPartitionName / #getPartitionValues / #getOrderedPartitionValues` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java:34` +- 复核收窄:ConnectorPartitionInfo 同时携带 3 种分区值表达(源渲染的 name 身份串 / 远端列名键 Map / 位置对齐 List+null 标志)且无一致性校验,并用 6 个望远镜构造器把“可省字段”编码在 arity 里;可折叠的是 Map(可由 ordered values + 远端列名派生)与构造器(改 Builder),partitionName 必须保留(源渲染身份串、含转义,API 无法派生)。 + +**81. ConnectorPartitionInfo 用三套并行表示同一份分区值,靠隐式位置对齐 + "空=未提供"三态,并堆了 6 个望远镜构造器** +- 严重度:中 判定:部分成立 符号:`ConnectorPartitionInfo#getPartitionValues / #getOrderedPartitionValues / #getPartitionValueNullFlags` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java:35` +- 复核收窄:ConnectorPartitionInfo 对分区值有两套并行表示(Map<远端列名,值> 与位置对齐的 orderedPartitionValues,外加一条位置对齐的 nullFlags 元数据列表),fe-core 的两条路径各只读一套且互不知情; + +**82. ConnectorSession 有三条重叠的配置读取路径,getProperty 悄悄把 session 与 catalog 两个命名空间合并且契约未写** +- 严重度:中 判定:成立 符号:`ConnectorSession#getProperty / #getCatalogProperties / #getSessionProperties` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java:73` + +**83. 分区列举有三套并存 SPI,其中 listPartitionValues 零生产调用方,javadoc 声称的调用者实际走 listPartitions** +- 严重度:中 判定:成立 符号:`ConnectorTableOps#listPartitionValues / #listPartitionNames / #listPartitions` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:493` + +**84. “连接器吃掉了哪些谓词/limit”有两套并存协议:FilterApplicationResult.remainingFilter 与 ScanNodePropertiesResult.notPushedConjunctIndices;limit 也有两条下推路径** +- 严重度:中 判定:部分成立 符号:`FilterApplicationResult#getRemainingFilter vs ScanNodePropertiesResult#getNotPushedConjunctIndices;ConnectorPushdownOps#applyLimit vs planScan(limit)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertiesResult.java:39` +- 复核收窄:“连接器消费了哪些谓词”确有两套并存协议且只有下标那套真正生效:FilterApplicationResult.remainingFilter 非 null 时 fe-core(PluginDrivenScanNode:886-894)一个 conjunct 都不摘(细粒度反查被标为 future enhancement),而真正的摘除只走 ScanNodePropertiesResult.notPushedConjunctIndices(全仓仅 es 覆写); + +**85. write 包与 ddl 包各有一套几乎重复的分区字段/排序字段值对象,且同一概念的 transform 参数用两种编码、getter 命名不一致** +- 严重度:中 判定:成立 符号:`write.ConnectorWritePartitionField / write.ConnectorWriteSortColumn vs ddl.ConnectorPartitionField / ddl.ConnectorSortField` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionField.java:29` + +**86. test_connection 开关有两套并存判定:引擎经 defaultTestConnection(),jdbc 连接器内部又硬编码字面量与默认值** +- 严重度:低 判定:成立 符号:`Connector#defaultTestConnection / JdbcDorisConnector#preCreateValidation` +- 位置:`fe/fe-connector/fe-connector-jdbc/src/main/java/org/apache/doris/connector/jdbc/JdbcDorisConnector.java:164` + +**87. 写侧能力有“provider 方法 + Connector 无参镜像 + Connector 按 handle 重载”三层并存表达,且 7 个 trait 中只有 4 个有按 handle 形态** +- 严重度:低 判定:部分成立 符号:`Connector#supportedWriteOperations/supportsWriteBranch/requiresXxx(+handle) vs ConnectorWritePlanProvider 同名方法` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:144` +- 复核收窄:写侧 trait 的真源只有 ConnectorWritePlanProvider 一处,Connector 上的无参/按 handle 方法都是 default 派生视图(零连接器覆写),不是三套并存机制; + +**88. MVCC 新鲜度种类/取值有三套并存表达,且 Freshness.LAST_MODIFIED 枚举值零使用** +- 严重度:低 判定:部分成立 符号:`ConnectorMvccPartitionView.Freshness vs ConnectorMvccSnapshot#isLastModifiedFreshness vs ConnectorMvccPartition#getFreshnessValue / ConnectorMetadata#getPartitionFreshnessMillis / ConnectorPartitionInfo#getLastModifiedMillis` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java:60` +- 复核收窄:准确表述:ConnectorMvccPartitionView.Freshness 的 LAST_MODIFIED 分支零使用(全部 5 个构造点均传 SNAPSHOT_ID),使该枚举实际退化为单值、fe-core PluginDrivenMvccExternalTable.java:213-214 的 false 分支无任何连接器行使——属零使用面(判据 3)。 + +**89. NULL 分区值有两套并存机制(结构化 null 标志 + hive 魔法字符串哨兵),且哨兵常量在 api 与 fe-core 各定义一份** +- 严重度:低 判定:部分成立 符号:`ConnectorPartitionValues.HIVE_DEFAULT_PARTITION / NULL_PARTITION_VALUE vs ConnectorPartitionInfo#getPartitionValueNullFlags` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java:26` +- 复核收窄:中立 api 里托管了 hive 品牌命名的哨兵常量与带 hive 文本语义的 normalize()(会把字面量 "\N" 误判 NULL,hive/paimon 已注释绕开),且 fe-core TablePartitionValues:47 存在第二份同串:这是命名中立性 + 常量重复的问题,不是“结构化标志与哨兵两套冗余机制”——哨兵服务分区名身份与 BE columns_from_path 的字节兼容(MTMV/TVF 已持久化),flag 服务 FE typed … + +**90. ConnectorPredicate 与 ConnectorFilterConstraint 是两个同形单字段包装类,null 契约相反,写侧语法子集(沿用 iceberg 冲突矩阵、静默丢弃 conjunct)在 API 上完全没写** +- 严重度:低 判定:部分成立 符号:`ConnectorPredicate / ConnectorFilterConstraint` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorPredicate.java:22` +- 复核收窄:准确表述:ConnectorPredicate 与 ConnectorFilterConstraint 是两个同形单字段 Serializable 包装类,null 契约相反(一个 requireNonNull、一个允许 null)且都缺 equals/hashCode; + +**91. 属性描述符体系整体零使用:ConnectorPropertyMetadata 与 Connector.getTableProperties()/getSessionProperties() 无任何实现与调用,且方法名与另外两处“properties”语义冲突** +- 严重度:低 判定:成立 符号:`ConnectorPropertyMetadata / Connector#getTableProperties / Connector#getSessionProperties` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:235` + +**92. ConnectorScanPlanProvider#estimateScanRangeCount 零调用方(与 streamingSplitEstimate/supportsBatchScan 的估算职责重叠)** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider#estimateScanRangeCount` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:425` +- 复核收窄:estimateScanRangeCount 是死方法:全仓库零生产调用方,唯一实现是 jdbc 的常量 `return 1`(JdbcScanPlanProvider.java:152),应连同 jdbc 实现一起删除。 + +**93. getScanNodeProperties 与 getScanNodePropertiesResult 两套并存机制:引擎只调后者,6 个连接器只实现前者,ES 两个都实现** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider#getScanNodeProperties / #getScanNodePropertiesResult` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:417` +- 复核收窄:准确表述:不是两套竞争机制,而是一个 default 钩子链(Result 版默认包装 map 版)。 + +**94. scan 属性有 Map 与包装对象两套返回面,引擎只调包装面;6 个连接器覆写 Map 面,同时覆写两者会静默丢一个** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider#getScanNodeProperties vs #getScanNodePropertiesResult` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:417` +- 复核收窄:ConnectorScanPlanProvider 对 scan 级属性提供“简单面 + 扩展面”两个可覆写方法(Map 面 :417 / Result 面 :455),引擎只调扩展面,扩展面的 default 显式委派简单面。 + +**95. planScan 四种重载 + 三个入口(planScan / planScanForPartitionBatch / streamSplits)参数集各不相同,引擎只调 7 参版,streamSplits 的 limit 恒为 -1** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider#planScan / #planScanForPartitionBatch / #streamSplits` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:138` +- 复核收窄:planScan 四级重载中的中间两级(158/195)是纯委派胶水,引擎只调 7 参版(PluginDrivenScanNode:1356),streamSplits 的 limit 参数是死参数(唯一调用点 :1757 恒传 -1L),三个入口参数集不对称——这些是真实的 API 冗余/形态问题(无冗余 + 语义清晰),宜收敛成单个扫描请求值对象并删掉 streamSplits 的 limit。 + +**96. planScan 四级望远镜重载:引擎只调最宽的一个,抽象方法却是最窄的,导致连接器出现不可达覆写** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider#planScan(4/5/6/7 参)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:138` +- 复核收窄:planScan 有 4 个望远镜重载而引擎只调最宽的 7 参(PluginDrivenScanNode:1356),属重载堆叠 + 双哨兵语义(limit=-1、requiredPartitions null/空=“未裁剪”)的可扩展性问题; + +**97. 旧的 createTable(session, ConnectorTableSchema, Map) 重载零实现零调用,只剩 api 内部一个会丢信息的降级桥** +- 严重度:低 判定:成立 符号:`ConnectorTableOps#createTable(ConnectorSession, ConnectorTableSchema, Map)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:223` + +**98. 两个“旧宽度”重载已是死脚手架:createTable(session, schema, props) 与 dropDatabase(session, db, ifExists) 零实现零调用** +- 严重度:低 判定:成立 符号:`ConnectorTableOps#createTable(ConnectorSession,ConnectorTableSchema,Map) / ConnectorSchemaOps#dropDatabase(ConnectorSession,String,boolean)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:223` + +**99. 主键信息有两套并存机制,且两套都没有 fe-core 生产读取方** +- 严重度:低 判定:成立 符号:`ConnectorTableOps#getPrimaryKeys 与 ConnectorTableSchema.PRIMARY_KEYS_KEY` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:417` + +**100. ConnectorTableStatistics.UNKNOWN / ConnectorColumnStatistics.UNKNOWN 两个 sentinel 零使用,且与接口的 Optional.empty() 约定冲突("未知"有三种表达)** +- 严重度:低 判定:部分成立 符号:`ConnectorTableStatistics#UNKNOWN / ConnectorColumnStatistics#UNKNOWN` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableStatistics.java:23` +- 复核收窄:ConnectorTableStatistics.UNKNOWN(:29) 与 ConnectorColumnStatistics.UNKNOWN(:36) 零使用,且其类 javadoc "Use UNKNOWN when unavailable" 与 ConnectorStatisticsOps 的 Optional.empty() 约定自相矛盾,另 ConnectorColumnStatistics.java:30 的 {@link #getTableStatistic… + +**101. ConnectorType 用 7 个构造器 + 9 个静态工厂 + 5 组并行 List 承载可选元数据,且 equals 只覆盖其中一部分** +- 严重度:低 判定:部分成立 符号:`ConnectorType 构造器族` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java:86` +- 复核收窄:准确表述:ConnectorType 用 7 个望远镜式构造器 + 9 个静态工厂承载 4 组可选并行 List(nullable/comment/fieldId/commentSpecified),每新增一项 facet 就要加一层重载,且并行列表长度一致性无任何校验(越界=未设置,静默)。 + +**102. OVERWRITE 用 isOverwrite() 布尔和 WriteOperation.OVERWRITE 两套编码,引擎从不产出 OVERWRITE,导致每个连接器重复同一段"提升"代码** +- 严重度:低 判定:成立 符号:`ConnectorWriteHandle#isOverwrite / WriteOperation.OVERWRITE` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorWriteHandle.java:44` + +**103. MetastoreChangeDescriptor.forTable 的 tableNameAfter 参数全部调用点都传 null,javadoc 却称它服务 RENAME_TABLE** +- 严重度:低 判定:成立 符号:`MetastoreChangeDescriptor#forTable` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/MetastoreChangeDescriptor.java:111` + +**104. 分区 transform 有两套编码:ddl 侧结构化 (transform + args) 与 write 侧 iceberg 逐字串 "bucket[16]",后者还让 fe-core 依赖 iceberg 的 toString 约定** +- 严重度:低 判定:部分成立 符号:`ddl/ConnectorPartitionField vs write/ConnectorWritePartitionField` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePartitionField.java:20` +- 复核收窄:真实缺陷限于 api 层:write/ConnectorWritePartitionField 的 javadoc 与字段命名把 iceberg 明确写进公共 api(transform 取值格式=iceberg PartitionField.transform().toString()、sourceId=iceberg source field id),且与 ddl/ConnectorPartitionField 的 (transform + transformArgs) … + +**105. handle 家族里两个空标记接口是死面:ConnectorPartitionHandle 零引用,api 的 ConnectorTransactionHandle 零 import(其存在理由“兼容既有 API”并不存在)** +- 严重度:低 判定:成立 符号:`handle/ConnectorPartitionHandle / handle/ConnectorTransactionHandle` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorPartitionHandle.java:25` + + +### A.5 一个接口捆绑了互不相干的职责(单一职责)(8 条) + +**106. ConnectorContext 是引擎服务大杂烩:19 个方法覆盖 9 类互不相干能力,其中 sanitizeJdbcUrl 只有一个连接器用** +- 严重度:中 判定:部分成立 符号:`ConnectorContext` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java:36` +- 复核收窄:ConnectorContext 把 9 类互不相干的引擎能力(目录身份、环境配置、HTTP 安全钩子、JDBC URL 消毒、认证、元数据失效回调、兄弟连接器工厂、9 个存储方法、BE 连通性探测)压在一个 19 方法接口上,违反单一职责:任何需要包装它的连接器必须逐个转发(iceberg 18 个 @Override、paimon 17 个),新增方法需同步改 2 个生产包装器与 5 个测试双。 + +**107. 嵌套字段元数据被建模两次:ConnectorColumn 的字段 vs ConnectorType 里 4 组平行 List,且创建入口达 14 个** +- 严重度:中 判定:部分成立 符号:`ConnectorType#childrenNullable/childrenComments/childrenFieldIds/childrenCommentSpecified vs ConnectorColumn` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java:63` +- 复核收窄:现象成立(顶层具名字段 vs 嵌套 4 组平行 List 双重建模、构造器望远镜 7 构造器+9 工厂+wither 并存、位置错位=静默元数据串位),但原文所有行号均无效(文件仅 334 行),静态工厂是 9 个不是 8 个,且“es 用宽构造器直接传平行参数(EsTypeMapping:132)”是错的——es 最宽只用到 4 参 ARRAY 构造器,真正用宽构造器/平行列表的是 iceberg(IcebergTypeMapping + withChildrenField… + +**108. Connector 单一入口接口塞进 6 类互不相干职责、25 个方法,其中 9 个只是 provider 布尔特性的镜像转发** +- 严重度:低 判定:部分成立 符号:`Connector` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:42` +- 复核收窄:Connector 入口接口共 34 个方法(33 default + 唯一抽象 getMetadata),其中真正冗余的是 9 个写特性布尔镜像(132/138/144/150/156/162/168/177/183,实现全为 `p != null && p.xxx()`,全仓库无任何连接器覆盖,只被 fe-core 当便捷入口用)——它们与 ConnectorWritePlanProvider 同名方法语义重复且给出第二个可覆盖入口; + +**109. 表达式值对象的不可变性/可序列化性不一致:And/Or/FunctionCall 不做防御拷贝、允许空列表,三个 ApplicationResult 与 ColumnAssignment 又都不可序列化** +- 严重度:低 判定:部分成立 符号:`ConnectorAnd(List) / ConnectorOr(List) / FilterApplicationResult` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorAnd.java:34` +- 复核收窄:真实残余:ConnectorAnd/ConnectorOr/ConnectorFunctionCall 只包 unmodifiable 视图而不做防御拷贝(调用方仍可通过原 list 改内容),与同包 ConnectorIn 的真拷贝不一致,且 javadoc 声明的“two or more”无任何校验;ConnectorExpression 的 Serializable 全仓无消费路径且被 ConnectorLiteral 的 Object value 削弱。 + +**110. beginQuerySnapshot 的 empty 语义是"不支持 MVCC",但 last-modified 新鲜度标记只能挂在 pin 上,逼得非 MVCC 的 hive 必须伪造一个假快照** +- 严重度:低 判定:部分成立 符号:`ConnectorMetadata#beginQuerySnapshot / ConnectorMvccSnapshot#isLastModifiedFreshness` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:59` +- 复核收窄:准确表述:这不是职责捆绑错误,而是 beginQuerySnapshot 的契约文档缺一句关键警示——lastModifiedFreshness 能力位只能随 pin 传递,返回 Optional.empty() 会被 fe-core 换成 flag=false 的 emptySnapshot,因此 last-modified 型连接器(hive)必须返回一个非空的 -1 pin。 + +**111. ConnectorScanPlanProvider 是 24 方法 / 23 个 default 的“上帝接口”,把分片生成、能力开关、列分类、压缩类型、EXPLAIN 渲染、profile、thrift 填充、查询生命周期全捆在一起** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:41` +- 复核收窄:ConnectorScanPlanProvider 确实把 8 类不同关注点(分片生成/能力位/列与压缩分类/属性下发/thrift 填充回读/EXPLAIN/profile/查询生命周期)聚在一个 542 行、24 方法接口里,内聚度差、可发现性差、每次能力扩展都动公共接口——这是真实的内聚度缺陷。 + +**112. 会话/目录配置读取有三条语义不同的路径且无使用规则:getProperty(会话→目录回退) / getSessionProperties() / getCatalogProperties()** +- 严重度:低 判定:部分成立 符号:`ConnectorSession#getProperty / #getSessionProperties / #getCatalogProperties` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSession.java:74` +- 复核收窄:ConnectorSession 上只有 getProperty 一个方法做“会话覆盖目录”的合并,且该优先级仅写在 fe-core 实现注释里、接口 javadoc 未声明;getCatalogProperties/getSessionProperties 是两个语义清楚的单一来源原始 Map。 + +**113. ConnectorTableOps 把 8 类互不相干职责与两种寻址风格(handle / 字符串名)捆在一个 46 方法接口里** +- 严重度:低 判定:部分成立 符号:`ConnectorTableOps` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:40` +- 复核收窄:ConnectorTableOps 是一个 46 个 default 方法、跨「元数据读 / 表列分区DDL / DML 直通 / thrift 描述符 / TVF 列推导」的宽接口,可发现性差(新连接器难判断最少实现集),且 buildTableDescriptor 用 7 个散列标量而非 handle 传递表身份(hudi:801 明写因此无法区分基表/系统表)。 + + +### A.6 实现与接口定义不符(一致性)(24 条) + +**114. ConnectorOr 被文档化为 N 元,但 trino 连接器只读前两个 disjunct,3 路 OR 会在源端丢行** +- 严重度:高 判定:成立 符号:`ConnectorOr#getDisjuncts / TrinoPredicateConverter#convertOr` +- 位置:`fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoPredicateConverter.java:114` + +**115. 表达式模型没有 CAST 节点,引擎静默剥壳;supportsCastPredicatePushdown 的 javadoc 承诺对 applyFilter 路径根本不成立,且默认值是不安全的 true** +- 严重度:高 判定:成立 符号:`ConnectorPushdownOps#supportsCastPredicatePushdown` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPushdownOps.java:60` + +**116. STRUCT 的 fieldNames 与 children 并行列表关系没有契约也无校验,trino 连接器丢掉字段名,引擎静默编造 col0/col1** +- 严重度:高 判定:成立 符号:`ConnectorType#getFieldNames / ConnectorType#structOf` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java:245` + +**117. ConnectorContext.sanitizeJdbcUrl 契约写「使用任何 JDBC URL 前必须调用」,iceberg/paimon 的 jdbc catalog 直接把用户 uri 交给 SDK 建连,从未调用** +- 严重度:中 判定:成立 符号:`ConnectorContext#sanitizeJdbcUrl` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java:72` + +**118. resolveTimeTravel 契约写「不支持的 spec 返回 empty」,三个连接器全部改为抛异常,且异常类型三不一致** +- 严重度:中 判定:部分成立 符号:`ConnectorMetadata#resolveTimeTravel` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:141` +- 复核收窄:resolveTimeTravel 的公共 javadoc 把“本源不支持该 Kind”与“目标不存在”都规定为 Optional.empty(),但 fe-core 只有 not-found 文案(notFoundMessage:471-495),于是 iceberg(INCREMENTAL,2036-2039)与 hudi(SNAPSHOT_ID/VERSION_REF,486-489)在可达路径抛 DorisConnectorException 来表达“不支持”,而这条… + +**119. ConnectorPartitionField.transform 的词表契约与实现全面不符:把分区风格 list/range 混进 transform、引用不存在的 CUSTOM、正规契约指向仓库外的 RFC 附录,实际连接器各接受不同别名集** +- 严重度:中 判定:成立 符号:`ConnectorPartitionField#getTransform` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorPartitionField.java:27` + +**120. ConnectorPartitionInfo.lastModifiedMillis 契约是 epoch 毫秒,hudi 填的是 Hudi instant(yyyyMMddHHmmssSSS 数字),把 SqlCache 静默窗口门禁算坏** +- 严重度:中 判定:成立 符号:`ConnectorPartitionInfo#getLastModifiedMillis` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java:166` + +**121. ConnectorScanRange#getLength() 契约写“字节数”,但 MaxCompute 在 row_offset 模式返回行数,引擎却无条件把它累加进 totalFileSize** +- 严重度:中 判定:部分成立 符号:`ConnectorScanRange#getLength` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:56` +- 复核收窄:ConnectorScanRange 的位置/长度契约(getStart 写“byte offset”、getLength 写“bytes”)与 MaxCompute 实现不一致:row_offset 模式 getLength=行数、getStart=行偏移,byte_size 模式 getStart=split 序号。 + +**122. 统计接口在 fe-core 后台统计线程上被调用且引擎不 pin TCCL,公共 API 完全没写这个契约,唯一实现靠自己补救** +- 严重度:中 判定:成立 符号:`ConnectorStatisticsOps` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java:27` + +**123. listFileSizes 的 javadoc 强制"出错必须返回空、不得抛异常",唯一实现却故意抛出,且有单测锁死抛出行为** +- 严重度:中 判定:成立 符号:`ConnectorStatisticsOps#listFileSizes` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java:93` + +**124. ConnectorType.typeName 实际必须使用 Doris 内部类型拼写,javadoc 却说是“连接器自己的类型系统”,未知名字静默退化为 UNSUPPORTED** +- 严重度:中 判定:部分成立 符号:`ConnectorType#getTypeName` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java:26` +- 复核收窄:ConnectorType 的 typeName 是无词表、无校验的裸字符串,实际必须使用 Doris 内部拼写(DATEV2 / DATETIMEV2 / JSONB / TIMESTAMPTZ / UNSUPPORTED),而 javadoc 称之为「连接器自己的类型系统」,且 DATETIMEV2 把小数秒位数放在 precision 槽位这一约定只写在 fe-core 的 ConnectorColumnConverter 注释里——引擎词汇泄漏 + 契约缺文档(原则 … + +**125. ConnectorContext.getHttpSecurityHook 的用法契约(对外 HTTP 前后调用)只有 es 遵守,iceberg/paimon 的 REST 目录用用户 uri 发 HTTP 却不过这个 SSRF 钩子** +- 严重度:低 判定:部分成立 符号:`ConnectorContext#getHttpSecurityHook` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java:57` +- 复核收窄:准确表述:ConnectorContext:57-65 的 SSRF 钩子用法契约未限定作用域,读者会误以为覆盖所有 FE 侧对外元数据请求;实测唯一遵守者是 es(唯一自建 HTTP 客户端的连接器),iceberg/paimon 的 REST 目录请求由第三方 SDK 发出、不经钩子——但这与迁移前 fe-core 行为一致(上游 master 的 datasource 目录内零 startSSRFChecking),不是新引入的防护回退,也不构成契约违反。 + +**126. ConnectorContractValidator 的 javadoc 声称“由各连接器的契约测试逐个调用 validate 来强制”,实际只有 4 个连接器调用,声明新分支的 hive 恰好未被覆盖** +- 严重度:低 判定:成立 符号:`ConnectorContractValidator#validate` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java:31` + +**127. 表注释有专用字段和 properties["comment"] 两套通道且未定优先级,四个连接器给出四种解释(iceberg 直接丢弃 COMMENT 子句)** +- 严重度:低 判定:部分成立 符号:`ConnectorCreateTableRequest#getComment / getProperties` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java:103` +- 复核收窄:表注释存在两条并行通道(COMMENT 子句字段 vs 引擎原生 PROPERTIES("comment")),API 未在 getComment() javadoc 中声明二者关系;连接器实际是三种行为(hive/maxcompute 只读字段、paimon properties 优先回退字段、iceberg 只把 properties 当原生表属性并丢弃 COMMENT 子句),且这三种均为 legacy 平价(master 上的 IcebergMetadataOps 同… + +**128. ConnectorEventSource.getCurrentEventId 的 javadoc 声明“master 用它先判断有没有新事件再调 pollOnce”,引擎里零调用方,游标完全由 pollOnce 结果驱动** +- 严重度:低 判定:成立 符号:`ConnectorEventSource#getCurrentEventId` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/event/ConnectorEventSource.java:44` + +**129. ConnectorSchemaOps 的 dbName 参数在兄弟方法间一个是本地名一个是远端名,javadoc 完全没约定** +- 严重度:低 判定:部分成立 符号:`ConnectorSchemaOps#createDatabase / #databaseExists / #dropDatabase` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorSchemaOps.java:35` +- 复核收窄:准确表述:ConnectorSchemaOps 的 dbName 参数缺少 LOCAL/REMOTE 契约文档——fe-core 在 dropDatabase/getDatabase 传远端名(PluginDrivenExternalCatalog.java:553、PluginDrivenExternalDatabase.java:85),在 createDatabase/其存在性预检传用户原样输入(:511/:516),连接器只能靠读 fe-core 源码推断。 + +**130. ConnectorStatementScope 的 close 后状态未定义,实现允许“关闭后复活”,此后写入的 AutoCloseable 值永不会被关闭** +- 严重度:低 判定:成立 符号:`ConnectorStatementScope#closeAll / ConnectorStatementScopeImpl#computeIfAbsent` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScope.java:59` + +**131. api javadoc 宣称“metadata:” key 族是引擎保留、无需源前缀,但 hive 连接器也在写同一族,且未记载的 “metadata-identity:” 族同样在共享** +- 严重度:低 判定:部分成立 符号:`ConnectorStatementScope#getOrCreateMetadata / ConnectorStatementScopes 键约定` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScopes.java:45` +- 复核收窄:“metadata:” 族由引擎(PluginDrivenMetadata:70)与 hive 网关(HiveConnectorMetadata:304)共写,但键形状(含 owner label 后缀)在 ConnectorStatementScope.java:49-50 已被 api javadoc 记载,非无文档;当前无冲突且误用会 fail-loud。 + +**132. listPartitions 的 filter 参数在生产中恒为 empty,却被三个连接器重新解释成"绕过缓存"开关** +- 严重度:低 判定:部分成立 符号:`ConnectorTableOps#listPartitions(ConnectorSession, ConnectorTableHandle, Optional)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:487` +- 复核收窄:准确表述:listPartitions 的 Optional filter 是投机参数——fe-core 全部 4 个调用点恒传 Optional.empty(),5 个连接器实现无一做谓词下推,属原则 3 的零生产用途参数面(可考虑删参)。 + +**133. 表级能力 CSV 通道只对 13 个能力中的 5 个生效,hive 却把兄弟连接器的全部能力都写进去,多余的被静默丢弃** +- 严重度:低 判定:部分成立 符号:`ConnectorTableSchema#PER_TABLE_CAPABILITIES_KEY / PluginDrivenExternalTable#hasScanCapability` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java:98` +- 复核收窄:表级 CSV 通道的实际生效范围(13 个 ConnectorCapability 中仅 5 个:COLUMN_AUTO_ANALYZE / TOPN_LAZY_MATERIALIZE / NESTED_COLUMN_PRUNE / NESTED_COLUMN_SCHEMA_CHANGE / SAMPLE_ANALYZE)没有写进 ConnectorTableSchema:82-97 的 javadoc,且解析对未知/不可细化名字静默忽略——这是公共 API 契约文档与实现的… + +**134. getUpdateCnt() 契约只写"受影响行数",但引擎依赖一个未在接口上定义的 -1 哨兵(只写在 NoOpConnectorTransaction 类注释里)** +- 严重度:低 判定:部分成立 符号:`ConnectorTransaction#getUpdateCnt` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorTransaction.java:72` +- 复核收窄:接口 ConnectorTransaction#getUpdateCnt 的 javadoc 未记录引擎实际依赖的 -1 =“本事务不产生行数、请沿用协调器计数”语义(该语义只在实现类 NoOpConnectorTransaction 的注释里),这是公共契约的文档缺口; + +**135. ConnectorTransaction 契约声明 rollback()/close() 可重复调用,hive 实现不满足(二次 rollback 会在已 shutdown 的线程池上再提交任务)** +- 严重度:低 判定:部分成立 符号:`ConnectorTransaction#rollback` +- 位置:`fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorTransaction.java:284` +- 复核收窄:hive 事务的 rollback() 没有幂等状态位,与接口“可重复调用”的契约存在落差(准则6),属实;但机理不是“二次 rollback 向已关闭线程池提交任务”——abort/rollback 的每一步都清空自身任务列表,第二次 rollback 实际退化为一次幂等的 staging 目录删除。 + +**136. EXPLAIN 路径传给 appendExplainInfo 的 ConnectorWriteHandle 是伪造的(overwrite 恒 false、writeContext 恒空、sortInfo 恒 null、branch 恒 empty),接口 javadoc 未告知** +- 严重度:低 判定:部分成立 符号:`ConnectorWritePlanProvider#appendExplainInfo` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java:56` +- 复核收窄:EXPLAIN 路径确实用占位值构造 ConnectorWriteHandle(overwrite=false、writeContext 空、sortInfo=null、branch empty),同一类型在两个调用点保真度不同,契约只做了间接提示(“绑定之前”+ @param 仅列出 table handle 与 write columns)而未逐项点名不可信的 getter; + +**137. WriteOperation.UPDATE 从来不是可声明的写能力:无连接器在 supportedOperations 里声明它,引擎准入只查 DELETE||MERGE,只支持 DELETE 的连接器会被放行执行 UPDATE** +- 严重度:低 判定:部分成立 符号:`ConnectorWritePlanProvider#supportedOperations / WriteOperation.UPDATE` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java:151` +- 复核收窄:WriteOperation 在“本次写的 op 轴”(含 UPDATE,fe-core 经 validateRowLevelDmlMode 真实传入)与“连接器能力集”(实测零连接器声明 UPDATE、引擎也不查)两处值域不同,而公共 api javadoc 未说明这一点; + + +### A.7 语义/契约不清(清晰性)(13 条) + +**138. ConnectorBucketSpec.algorithm 是只在 javadoc 里定义的字符串枚举,两个连接器各自复制字面量 "doris_random"/"doris_default",api 未提供任何常量** +- 严重度:中 判定:成立 符号:`ConnectorBucketSpec#getAlgorithm` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorBucketSpec.java:65` + +**139. scan node properties 用 Map 偷运结构化信息:key 契约未在公共 API 定义,散落在 fe-core private 常量(含 hive.text.* 源专有前缀)与连接器字面量两侧** +- 严重度:中 判定:成立 符号:`ConnectorScanPlanProvider#getScanNodeProperties (Map 契约)` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java:127` + +**140. ConnectorScanRange#getPartitionValues() 返回 Map 却被引擎按 values() 位置消费,与 path_partition_keys 的顺序强绑定,契约里完全没写“必须有序”** +- 严重度:中 判定:部分成立 符号:`ConnectorScanRange#getPartitionValues` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:129` +- 复核收窄:准确表述:这是一个**潜伏**的未声明有序性契约,不是现役连接器的活跃错位风险。 + +**141. scan 节点属性 Map 的 key 契约(含 hive.text.* 源名前缀)私有于 fe-core,新连接器只能靠读 fe-core 源码抄字符串** +- 严重度:中 判定:成立 符号:`PluginDrivenScanNode PROP_* 常量 / ConnectorScanPlanProvider 属性 map` +- 位置:`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java:127` + +**142. getSnapshotId() 没有定义"未知/无快照"取值:builder 默认 0,而 fe-core 空 pin 与 iceberg 统计都按 -1/<0 判断,property-only pin 会静默得到 0** +- 严重度:低 判定:部分成立 符号:`ConnectorMvccSnapshot#getSnapshotId` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java:54` +- 复核收窄:准确表述:ConnectorMvccSnapshot 内部约定不一致——schemaId 显式文档化 -1=unknown 且 Builder 默认 -1,而 snapshotId 的 javadoc 未定义任何"无快照/不适用"取值、Builder 默认 0,尽管 fe-core(emptySnapshot=-1) 与 iceberg/paimon(判 <0) 事实上都按 -1 约定。 + +**143. 过程参数与结果都是无契约的字符串容器:SPI 只给 Map 和 List>,参数 schema 完全留在连接器内部,引擎无法校验也无法展示** +- 严重度:低 判定:部分成立 符号:`ConnectorProcedureOps#execute(properties) / ConnectorProcedureResult#getRows` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java:117` +- 复核收窄:过程参数走 Map 透传是 javadoc(ConnectorProcedureOps.java:103-105)显式声明的职责划分(连接器持有参数 schema,引擎不感知),不构成「未定义 key 契约的结构化偷运」;真实缺口收窄为:(a) ConnectorProcedureResult 未定义 rows 的值字符串化与 null 编码约定(:52-55 只约定宽度),(b) ConnectorExecuteAction.wrapResul… + +**144. listTableNames 是否包含视图未定契约,两种相反约定并存,靠 fe-core 注释假设二者互斥** +- 严重度:低 判定:部分成立 符号:`ConnectorTableOps#listTableNames / #listViewNames` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:176` +- 复核收窄:listTableNames 的 javadoc 未规定返回集合是否包含视图(只有 listViewNames 的 javadoc 间接暗示「二者不相交」),fe-core 的合并 addAll 也不去重;hive(含视图 + listViewNames 空)与 iceberg(不含视图 + listViewNames 补回)两种相反约定各自自洽,且各配了守卫单测。 + +**145. ConnectorWriteHandle.getWriteContext() 用无 key 契约的 Map 偷运静态分区值,方法名与内容不符(javadoc 自己承认)** +- 严重度:低 判定:部分成立 符号:`ConnectorWriteHandle#getWriteContext` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/ConnectorWriteHandle.java:47` +- 复核收窄:getWriteContext() 名字与内容不符(实际只承载静态分区 spec,javadoc 自陈),且缺少**值编码**契约(NULL 分区值、日期/时间戳字面量、含逗号/等号的值在 maxcompute 的 "k=v" 拼串下无转义规范);key 契约本身已在 javadoc 写明(分区列名→值),三个消费者语义一致,null 防御属冗余(生产侧已归一非 null)。 + +**146. getWriteSortColumns 的 null/空列表三态契约容易误读:iceberg 实现自身的 javadoc 就写反了("unsorted 返回空列表"),与 API 契约和它自己的代码矛盾** +- 严重度:低 判定:部分成立 符号:`ConnectorWritePlanProvider#getWriteSortColumns` +- 位置:`fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java:253` +- 复核收窄:公共 API ConnectorWritePlanProvider#getWriteSortColumns 的 null/空 list/非空 list 三态在接口 javadoc(:82-89) 里已写清、fe-core 消费正确,不构成 API 契约不清;真实缺陷仅是唯一实现 IcebergWritePlanProvider:253 的方法级 javadoc 写反(“unsorted 返回空列表”,代码实为返回 null),一行陈旧注释,修连接器注释即可。 + +**147. SPI 没有定义异常契约:DorisConnectorException 只是空壳基类,连接器实际混用 4 个异常族,fe-core 只翻译其中一族** +- 严重度:低 判定:部分成立 符号:`DorisConnectorException` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/DorisConnectorException.java:23` +- 复核收窄:公共 SPI 缺少成文异常契约这一点成立且可核实:DorisConnectorException(:23) 无错误分类/子类型,整个 fe-connector-api 仅 12 个 @throws,Connector#preCreateValidation(:272) 在公共 SPI 上暴露裸 `throws Exception`,fe-core 的 32 个 catch 只认 DorisConnectorException 一族。 + +**148. "remaining filter" 在同一套下推词汇里有两个相反含义,且 FilterApplicationResult 的 null 语义只写在 fe-core 注释里、无任何实现真正使用部分下推** +- 严重度:低 判定:部分成立 符号:`FilterApplicationResult#getRemainingFilter / ConnectorScanPlanProvider#planScan(filter)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/FilterApplicationResult.java:45` +- 复核收窄:getRemainingFilter() 无 javadoc,而其 null 值在引擎里等价于“全部谓词已下推→clear conjuncts”,是一个影响正确性的隐式开关,生产实现从不返回 null(hive/hudi/trino 一律回传原表达式=保守保留),细粒度 conjunct 消除也确未实现。 + +**149. PartitionFieldChange 用一个 8 字段扁平 DTO 承载 add/drop/replace 三种语义,字段含义随被传入哪个方法而变,且无任何构造期校验** +- 严重度:低 判定:部分成立 符号:`PartitionFieldChange` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/PartitionFieldChange.java:40` +- 复核收窄:PartitionFieldChange 确是一个 8 字段全可空、按传入方法切换三套读法、构造期零校验的公共 DTO(语义清晰度问题成立);但严重性应下调:生产侧构造被约束在 fe-core 单文件的三个具名工厂(ConnectorPartitionFieldConverter:38/44/51,add/drop 恒传 null old*),消费侧仅 iceberg,故「位置参数错位/误读 old*」在当前代码没有真实暴露路径,属可读性与未来扩展成本问题,而非潜在错改分区字… + +**150. api 与 spi 的分层规则说不清且自相矛盾:同类“引擎服务”一半在 api 一半在 spi;spi 声称 Thrift-free 但它依赖的 api 就依赖 fe-thrift** +- 严重度:低 判定:部分成立 符号:`package-info(org.apache.doris.connector.spi) / ConnectorValidationContext / ConnectorBrokerAddress` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/package-info.java:18` +- 复核收窄:api/spi 的归属规则确实没写进 package-info(唯一可行的文档改进),且 ConnectorHttpSecurityHook 放在 api 缺少结构性理由(api 内无引用,仅被 spi 的 ConnectorContext 与 es 连接器使用),可移入 spi。 + + +### A.8 公共接口泄漏引擎内部(抽象泄漏)(15 条) + +**151. getTableFormatType() 名义上是中立字符串,实际是 BE 里硬编码的数据源名白名单,中立默认值 "plugin_driven" 在 BE 无任何分支** +- 严重度:高 判定:成立 符号:`ConnectorScanRange#getTableFormatType` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:113` + +**152. ConnectorFunctionCall.functionName 被复用来偷运 Doris 方言 SQL 片段与算术运算符号,约定只存在于 jdbc 连接器的正则里** +- 严重度:中 判定:部分成立 符号:`ConnectorFunctionCall#getFunctionName` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorFunctionCall.java:26` +- 复核收窄:准确表述:`ConnectorFunctionCall.functionName` 被引擎复用为三种互不相同的载荷(真函数名、算术运算符号、Expr.toSql() 渲染出的整段 Doris 方言 SQL 片段),而公共 API javadoc 只把它描述为函数名,既无 kind 枚举也无编码约定——识别规则事实上只存在于 fe-connector-jdbc 的一条注释+正则里。 + +**153. 公共 API 的方法签名直接暴露引擎 thrift 生成类,而这些 thrift 结构本身是按数据源封闭的 union** +- 严重度:中 判定:部分成立 符号:`ConnectorScanRange#populateRangeParams / ConnectorScanPlanProvider#populateScanLevelParams / ConnectorSinkPlan / ConnectorWriteHandle#getSortInfo` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:20` +- 复核收窄:准确表述:thrift 生成类只出现在 fe-connector-api(4 文件 / 6 个方法签名),fe-connector-spi 保持零 thrift 且有 javadoc 明示该边界(ConnectorContext.java:255/276-281);即项目**有意**让「连接器侧 api 持有 thrift、引擎侧 spi 中立」。 + +**154. 公共 API 直接暴露 fe-thrift 生成类,且 BE 线协议按数据源开洞:新连接器必须改 gensrc/thrift 共享 IDL** +- 严重度:中 判定:部分成立 符号:`ConnectorScanRange#populateRangeParams / ConnectorSinkPlan(TDataSink)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:20` +- 复核收窄:fe-connector-api 确实直接 import fe-thrift 生成类(4 个文件 7 处),且 TTableFormatFileDesc/TDataSinkType 是按数据源命名的固定槽位;但这不是可消除的抽象泄漏:BE 为 C++ 且无 FE 插件机制,任何需 BE 解释的负载都必须改 gensrc/thrift + BE,中立 IDL 字段也救不了这一点,api 的 fe-thrift 依赖是 pom 中显式记录的 provided-scope 决策。 + +**155. getMetadata 的实例生命周期/线程安全没写进契约,引擎却按“每语句一实例并在语句结束关闭”使用;而 ConnectorMetadata.close 无任何连接器实现** +- 严重度:低 判定:部分成立 符号:`Connector#getMetadata / ConnectorMetadata#close` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:45` +- 复核收窄:缺口是“契约文档位置错、可发现性差”,而非“未定义”:per-statement 单实例 + 语句结束关闭 + close-once 幂等的生命周期已在公共 api 的 ConnectorStatementScope.java:22-37/:47-57/:59-66 成文,并非只存在于 fe-core 注释; + +**156. ConnectorColumn 没有"改名/拷贝"操作,导致 fe-core 做标识符映射时静默丢掉 invisible / uniqueId / reservedPassthrough 等标记** +- 严重度:低 判定:部分成立 符号:`ConnectorColumn(缺 withName),fe-core PluginDrivenExternalTable#toSchemaCacheValue` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorColumn.java:93` +- 复核收窄:准确表述:ConnectorColumn 没有 withName/builder,fe-core PluginDrivenExternalTable.toSchemaCacheValue(496-505)在列名映射时用 6 参构造器手工重建,只补回 withTimeZone,静默丢掉 visible/uniqueId/reservedPassthrough/nullableSpecified/commentSpecified/isAutoInc/isAggregated ——… + +**157. ConnectorLiteral 把 Doris 内部类型名(DECIMALV3/DATEV2/DATETIMEV2)写进公共工厂,值域白名单与实际产出不符,8 个连接器各自重写值/类型嗅探** +- 严重度:低 判定:部分成立 符号:`ConnectorLiteral#getValue / ofDate / ofDatetime / ofDecimal` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLiteral.java:29` +- 复核收窄:问题实为契约文档缺口而非 ConnectorLiteral 独有的抽象泄漏:(1) ConnectorLiteral javadoc 的值域白名单不准(列了 Integer,但 IntLiteral 实际产出 Long),且未说明“未列举字面量(LARGEINT/ARRAY/MAP/IP 等)会降级为引擎渲染字符串”; + +**158. ConnectorMvccSnapshot 声称自己会被"序列化进 BE scan range"、properties 会"propagated to BE",引擎实际两件事都不做** +- 严重度:低 判定:部分成立 符号:`ConnectorMvccSnapshot / ConnectorMvccSnapshot#getProperties` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccSnapshot.java:31` +- 复核收窄:ConnectorMvccSnapshot 的类 javadoc(:31) 与 getProperties(:77) 用被动语态描述"serialized into BE scan ranges / propagated to BE",施动者含糊:引擎自身不做任何 thrift 序列化,只把 pin 交回连接器(applySnapshot / 带 snapshot 的 schema 与统计重载); + +**159. ConnectorProvider 为满足 PluginFactory 泛型约束而继承了一个必抛异常的 create(),而 Connector 并不是 Plugin** +- 严重度:低 判定:部分成立 符号:`ConnectorProvider#create() (no-arg, from PluginFactory)` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java:93` +- 复核收窄:ConnectorProvider 为满足 DirectoryPluginRuntimeManager 的类型上界而继承了一个唯一实现即抛异常的 Plugin create(),属于框架约束泄漏到公共 SPI 的表面冗余(原则 3/8),但当前不可达:连接器只注册 ConnectorProvider 服务名,PluginFactory 的 ServiceLoader 取不到它,且 PluginLoader.loadPlugin… + +**160. 公共扫描 API 直接以 thrift 生成类为参数/返回值,并让连接器回读自己刚填过的 thrift 结构(getDeleteFiles)** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider#adjustFileCompressType / #populateScanLevelParams / #getDeleteFiles、ConnectorScanRange#populateRangeParams` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:24` +- 复核收窄:准确表述:thrift 出现在公共扫描签名上是本架构的有意取舍(fe-thrift provided 依赖 + per-source BE 结构体 + fe-core 必须保持中立),不是可直接判违规的抽象泄漏;确有的成本是 thrift 结构演进会同时波及所有连接器。 + +**161. getDeleteFiles 把引擎刚写完的 thrift 结构再递回连接器,让它去读自己那一路源专有子结构** +- 严重度:低 判定:部分成立 符号:`ConnectorScanPlanProvider#getDeleteFiles(TTableFormatFileDesc)` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:506` +- 复核收窄:现象成立但降级为 low 的设计整洁性问题:getDeleteFiles(TTableFormatFileDesc) 让连接器把自己刚写进 thrift 的 delete 路径再解析回来(iceberg/paimon 各一处),可改为在 ConnectorScanRange 上以中立 getDeleteFilePaths() 直接给出。 + +**162. 默认 populateRangeParams 把所有连接器的通用属性塞进 thrift 的 jdbc_params 字段** +- 严重度:低 判定:部分成立 符号:`ConnectorScanRange#populateRangeParams` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:182` +- 复核收窄:默认 populateRangeParams 不是死路——它是 JDBC 连接器实际使用的路径(JdbcScanRange 未 override 且 table_format_type="jdbc",BE file_scanner.cpp:1157-1166 正是为它而读)。 + +**163. ConnectorTableSchema 用属性 Map + CSV 偷运结构化信息(分区列/主键/分布列/能力集)与预渲染的 Doris SQL 片段** +- 严重度:低 判定:部分成立 符号:`ConnectorTableSchema.PARTITION_COLUMNS_KEY / PRIMARY_KEYS_KEY / DISTRIBUTION_COLUMNS_KEY / PER_TABLE_CAPABILITIES_KEY / SHOW_*_KEY` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableSchema.java:74` +- 复核收窄:准确表述:结构化信息(分区列/主键/分布列/per-table 能力集)以**未类型化、无转义的 CSV** 经与用户属性同居的 properties Map 传递,fe-core 用散落 5 处的 split 解析且无校验——这是类型安全与可维护性缺陷(提升为 ConnectorTableSchema 一等字段无客观障碍:该类不参与 Gson 持久化、只是进程内缓存值)。 + +**164. validateAndResolveDriverPath / computeDriverChecksum 用中立名字包装 fe-core JdbcResource 的 JDBC 驱动 jar 语义** +- 严重度:低 判定:部分成立 符号:`ConnectorValidationContext#validateAndResolveDriverPath / #computeDriverChecksum` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorValidationContext.java:50` +- 复核收窄:准确表述:validateAndResolveDriverPath/computeDriverChecksum 不是“中立名包装单源语义”的中立性/泄漏问题(JDBC 驱动 jar 是 jdbc/paimon-jdbc/iceberg-jdbc 三连接器共用的概念,且白名单规则必须由引擎提供,插件无法 import fe-core JdbcResource)。 + +**165. planWrite 与事务的绑定走 session 隐式通道 + 各连接器自写同一段强转 helper;planWrite/beginTransaction 的 javadoc 都没写这条契约,也没建模 beginWrite 阶段** +- 严重度:低 判定:部分成立 符号:`ConnectorWritePlanProvider#planWrite` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java:44` +- 复核收窄:真实缺陷=(a)“planWrite 需自行从 session 取当前事务并向下强转”这条契约没写在 planWrite 的 javadoc 上(虽已写在 ConnectorWriteOps.beginTransaction 及其 per-handle 重载的 javadoc 里),(b)三个连接器各存一份逐字同构的取事务+强转 helper 与错误文案,缺一个 api 侧 typed helper。 + + +### A.9 成对能力的缺口(对称性)(7 条) + +**166. hive 网关为 12 个 DDL 转交了 iceberg 兄弟,却漏了 5 个 path 形态的列 DDL(含唯一入口 modifyColumnComment),iceberg-on-HMS 上 MODIFY COLUMN COMMENT 直接不可用** +- 严重度:中 判定:成立 符号:`ConnectorTableOps#modifyColumnComment / addNestedColumn / dropNestedColumn / renameNestedColumn / modifyNestedColumn` +- 位置:`fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnectorMetadata.java:1796` + +**167. 写特性的 per-handle 重载只做了 6 个中的 3 个,异构网关的另外 3 个特性只能拿到目录级答案** +- 严重度:低 判定:部分成立 符号:`Connector#requiresFullSchemaWriteOrder / #requiresPartitionLocalSort / #requiresParallelWrite` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:144` +- 复核收窄:写特性 6 项中 3 项缺 per-handle 变体、异构 HMS 网关下 requiresParallelWrite/requiresFullSchemaWriteOrder/requiresPartitionLocalSort 只能拿目录级(hive provider)答案,这一事实成立,且当前 hive 与 iceberg 三项取值一致 → 纯潜在缺口、无现网错误。 + +**168. 写能力开关的"连接器级 vs 表级"不对称:7 个开关里只有 4 个有 per-handle 视图,另 3 个异构网关无法按表变化(注释自陈靠"两边恰好都 true"侥幸成立)** +- 严重度:低 判定:部分成立 符号:`Connector#requiresParallelWrite / #requiresFullSchemaWriteOrder / #requiresPartitionLocalSort` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:143` +- 复核收窄:7 个写能力开关中只有 4 个提供 (ConnectorTableHandle) per-handle 视图,requiresParallelWrite / requiresFullSchemaWriteOrder / requiresPartitionLocalSort 缺失,异构网关只能拿连接器级答案——属真实的 API 对称性缺口(fe-core 注释亦自陈依赖“两个兄弟取值恰好相同”)。 + +**169. 建表请求没有主键字段:paimon 只能从 properties["primary-key"] 偷运主键,而读侧却有一等公民 getPrimaryKeys()** +- 严重度:低 判定:部分成立 符号:`ConnectorCreateTableRequest / ConnectorTableOps#getPrimaryKeys` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ddl/ConnectorCreateTableRequest.java:42` +- 复核收窄:写侧缺主键字段确实与读侧 ConnectorTableOps#getPrimaryKeys(jdbc 已实现,非空默认)不对称,但原因不是 API 把结构化信息塞进 Map:外部建表在 fe-core 分析期就禁止 keys desc(CreateTableInfo.java:775-778)并把所有列标 isKey=true(:802-804),引擎侧无任何主键语法可下传,"primary-key" 是 Paimon 原生表选项、经 PROPERTIES 透传属于设计内通道… + +**170. “改写 table handle”的一族方法分裂成两种返回约定,applySnapshot 的 javadoc 声称与 applyFilter 同构但实际不同构** +- 严重度:低 判定:部分成立 符号:`ConnectorMetadata#applySnapshot / ConnectorPushdownOps#applyFilter` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:164` +- 复核收窄:两族 apply* 返回约定不同(Optional+结果对象 vs 裸 handle、返回入参表示未处理)是事实,但“javadoc 声称二者同构/契约与文档不一致”不成立:mirrors 指的是“下推结果通过改写 table handle 承载”这一模式,而 Optional+结果对象是 applyFilter 族的必要形态(要回传残余谓词并让引擎判定是否保留过滤),applySnapshot 族无额外产物、调用方必用返回值,裸 handle 是合理签名。 + +**171. apiVersion 不匹配在两条引擎路径上行为相反:一条 warn 后静默跳过,一条直接抛** +- 严重度:低 判定:成立 符号:`ConnectorProvider#apiVersion` +- 位置:`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java:79` + +**172. 列统计只有"单列、无快照"一种形态:表统计有 snapshot 重载而列统计没有,且底层批量能力被 singletonList 包成逐列往返** +- 严重度:低 判定:部分成立 符号:`ConnectorStatisticsOps#getColumnStatistics` +- 位置:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java:67` +- 复核收窄:准确表述:列统计 SPI 只有"单列 + 最新快照"一种形态,与同接口内表统计的 snapshot 重载不对称,且底层 HMS API 本是批量的(hive 被迫 singletonList)。 + +--- + +## 附录 B:核查方法与可复核性 + +### B.1 这些结论是怎么得到的 + +- **独立审查单元 14 个**:9 个按接口分区 + 5 个按横切维度(可扩展性卡点穷举、源专有语义扫描、死接口面普查、契约一致性核对、重复机制排查)。互相不可见,均被要求只看代码、不得阅读任何已有评审文档。 +- **对抗式复核 30 个批次**:默认立场是「这条发现是错的」,必须在代码上亲自验证;对「无调用方 / 无实现方」类断言重跑覆盖全仓库的搜索(`fe-core`、8 个连接器、测试、be-java-extensions、服务发现配置、反射字符串);对建议检查客观障碍(`fe-core` 只出不进的隔离纪律、Gson 持久化兼容、跨插件类加载器边界、FE 与 BE 的有线格式兼容)。 +- **方案设计 3 个单元**:分别针对可扩展性、职责耦合与死接口、中立化与契约澄清,均要求在代码上核实现状后给出可分批落地的步骤,并明确「不建议动」的部分。 +- **我本人的独立通读**:`Connector`、`ConnectorMetadata`、`ConnectorType`、`ConnectorCapability`、`ConnectorSession`、`ConnectorProvider`、`ConnectorContext` 全文,六个 `Ops` 子接口与两个 Provider 的全部方法签名,以及若干条最易误判结论的亲手复核。 + +### B.2 几条关键结论的复核方式(可直接重跑) + +| 结论 | 复核方式 | +|---|---| +| 属性描述符体系是死的 | 在 `fe/` 下搜索 `ConnectorPropertyMetadata`,排除公共模块自身后应为零命中 | +| 分区句柄是死的 | 搜索 `ConnectorPartitionHandle`,全仓仅自身声明一处 | +| 分片类型枚举只被测试使用 | 搜索 `ConnectorScanRangeType`,主源零命中,只有引擎侧测试的匿名类在实现 | +| `listPartitionValues` 零生产调用方 | 在 `fe-core/src/main` 下搜索该方法名应为零命中;分区值表函数的真实取值路径是 `PluginDrivenExternalTable.getNameToPartitionValues`(`:882`),它在 `:898-899` 调 `listPartitions`;分区系统表那条路径在 `MetadataGenerator.java:1270` 用 `listPartitionNames` | +| 契约校验器没有在真实连接器上跑 | 搜索 `ConnectorContractValidator`,唯一调用方是引擎侧一个使用**伪造**连接器的测试;连接器侧只有 4 个模块的测试调用它,声明了分区哈希写的 hive 不在其中 | +| 两套相反的 thrift 规则 | 在 `fe-connector-api` 下搜索 `org.apache.doris.thrift` 有 6 个文件命中;`fe-connector-spi` 侧对照 `ConnectorContext.getBackendFileType` 的返回类型(字符串)与 `ConnectorBrokerAddress` 的存在理由 | +| 类型白名单 | `CatalogFactory.java:56` 的集合定义,判定点 `:110` 与 `:119` | +| 按表能力只对 5 个能力生效 | 引擎侧唯一读 CSV 的私有方法在 `PluginDrivenExternalTable.java:302`,数它的调用方;其余能力的读点直接调 `getCapabilities().contains(...)` | + +### B.3 已知的口径与局限 + +- **端到端行为未在真集群验证**。本轮是静态审查 + 单元级证据,所有「引擎侧从不调用」「实现违背契约」类结论都基于代码路径分析。涉及行为的整治批次必须配端到端回归。 +- **BE 侧只做了必要的交叉确认**(分片格式路由键的白名单式消费、分区变换串的正则解析、一个属性键在 BE 侧零命中)。没有对 BE 做系统性审查。 +- **本文不覆盖 `fe-connector-cache`、`fe-connector-metastore-api` / `-spi`** 这几个共享框架副本模块,它们不在用户指定的范围内。 +- **超支说明**:本轮实际消耗远超单任务的既定 token 预算(14 个审查单元 + 30 个复核批次 + 3 个方案单元,跨 `fe-core` / 公共模块 / 8 个连接器 / BE 四层交叉验证)。按「不静默超支」的要求在此明示。这是用户本次显式要求「独立调研任务」并开启最高投入档位的结果。 + +--- + +## 附录 C:与同日另一份评审文档的交叉核对 + +对照对象:`plan-doc/connector-api-spi-design-review-2026-07-25.md`(689 行,同日早些时候另一轮审查的产物)。核对由一个独立单元完成,它同时持有两边的结论,并**逐条回到代码上实测**,而不是比较文字。 + +### C.1 总体:两轮独立结论高度重叠 + +两轮在约 35 个问题上独立地指向了同一处代码——这是本次调研可信度最强的证据。重叠区里两边的准确度互有胜负: + +- **那份文档更准的地方**:`ConnectorTableOps` 的规模(它写「43 个方法名 / 46 个声明,3 组重载」,比本文的「46 个方法」更精确);`ConnectorSession` 三条读配置路径中「只有 hive 的两处在用 `getProperty`」这一实证完全正确;写特性的按表缺口一节的事实核对完全正确;公共模块里 thrift 出现位置的清单完全正确(4 个文件的 import + 1 处内联全限定名)。 +- **本文更准的地方**:类型白名单不能简单删除(它漏了 hudi 没有独立目录类型这个硬约束);分片格式路由键的中立默认值在 BE 会**硬失败**而不只是「缺约束」;分区空值哨兵**不能**下沉到 hive 连接器;`ConnectorMetaInvalidator` 是死接口应删而不是改名;`planScan` 的四参抽象方法**不是**死方法(不可达的只是三个连接器的窄覆写)。 + +### C.2 那份文档需要修正的地方 + +**五处事实错误**: + +1. **「8 个真实连接器没有任何一个调用过契约校验器」——错。** 实测有 4 个连接器的契约测试在调用(iceberg / es / maxcompute / jdbc 各一处)。由此推出的「这些规则今天完全没有被验证」「注释描述了并不存在的机制」两条结论随之作废。真实缺口精确到一点:唯一声明了分区哈希写的 hive 没有调用点,所以那两条不变量缺真实连接器正样本。(本文主题十一与主题十六第 13 条采用的是修正后的版本。) +2. 「7 个连接器实现了分片类型方法」——实测 **8 个**(漏了 trino)。 +3. 「分片类型的两个方法都没有默认实现」——实测只有分片上那个是抽象的,扫描计划提供者上那个有默认值。 +4. 「写侧分区规格存在『空规格=另一回事』的第三态」——代码与文档里都不存在,是虚构的第三态。该处文档写得很清楚(明确写了「`null`(不是空规格)表示目标表无分区」并给了理由)。 +5. 规模数字若干处偏差:入口接口 34 个方法(非 32)、会话接口 15 个(非 14)、分区信息值对象 6 个构造器(非 8)、公共 API 模块 10149 行(非约 9800)。 + +**三处需要改结论(不是改数字)**: + +1. 推模型的失效接口应**删除**,不是改名——它零连接器调用,且引擎侧履约不了它的分区粒度契约。 +2. 分区空值哨兵**不能下沉**到 hive 连接器:引擎自己的路径解析在用它,非 hive 连接器(paimon)主动把空分区名归一到它,而且引擎侧已经存在第二份同串定义——下沉只会变成三份。 +3. 压缩类型调整方法**不算中立性违规**:它的文档存在的目的恰恰是把 Hadoop 专有事实圈在连接器里、让通用节点保持与连接器无关。那份文档在两个小节里对同一处给了互相打架的定性。 + +### C.3 那份文档独有、经核实成立、应当并入的六条 + +这六条本轮没有覆盖到,实测成立,补记在此: + +1. **`ConnectorScanRange.getFileFormat()` 的默认值是 `"jni"`**(`:67-68`),把「文件格式」与「读取机制」混在一个字段里。5 个连接器覆写它,jdbc / trino / maxcompute 吃默认值。这一条与本项目已知的一个坑直接相关:扫描级的格式类型决定 BE 走 V1 还是 V2 读取器,发错值会把整个连接器永久钉在 V1。 +2. **`supportsCreateDatabase()` 与 `createDatabase()` 必须手工同进同退**(`ConnectorSchemaOps.java:58` / `:63`):只实现后者会跳过引擎的远端预检,导致「远端已存在但本 FE 缓存没有」时抛远端错误而不是静默成功。四家连接器成对实现。**但那份文档提的修法(删布尔位、靠异常判定)不可行**——那个布尔门的作用正是不给不支持建库的连接器发无谓的远端查询,删掉会让 jdbc / es / trino 每次「若不存在则建库」都多打一次远端。正解是保留布尔位 + 补一条契约测试。 +3. **带快照与不带快照的成对重载堆叠**:读 schema 与取列句柄各有一对,表统计有带快照的重载而列统计没有。 +4. **`buildTableDescriptor` 用内联全限定名写 thrift 返回类型**(`ConnectorTableOps.java:464`)——风格与公共模块其它 5 处 `import` 不一致,本身就是「知道这里不该出现它」的痕迹。(本文主题六已收录这一点。) +5. **「提供者每次新建、无状态」与「要求它释放跨调用的读事务」自相矛盾**:入口接口文档原文写着提供者是每次调用新建且无状态(`Connector.java:75-77`),而扫描计划提供者上却有一个释放读事务的方法(`:540`),hive 只能把状态挂在连接器级的管理器上。这是契约内部的自相矛盾(公共模块已用查询标识作跨调用键把这条缝写进契约),属文档层问题,实现不会踩雷。 +6. **按名字寻址导致异构网关拿不到表注释**:`getTableComment` 用库名 + 表名寻址(`ConnectorTableOps.java:423`),默认返回空串,hive 网关**完全没有覆写**它,而引擎正是用它实现表注释。后果:同一张 Iceberg 表在独立 Iceberg 目录下有注释,挂在 Hive 元存储目录下拿到空串;hive 自身的表注释也恒为空。**这是「按名字寻址无法委派给兄弟连接器」这个设计问题的第一个可观测代价**,值得单独修。 + +### C.4 本轮独有、优先级应排在那份文档全部条目之前的三条 + +那份文档在结尾自陈未覆盖三个包(下推、建表请求、多版本),并判断「这三组主要是值对象,中立性上没有明显问题」。本轮恰好在这三块查出了整份材料里最严重的几条,说明那句判断下得太早: + +1. **三路以上 OR 谓词会丢行**(trino 连接器只读前两个 disjunct)——**全部材料里唯一可能静默少数据的问题**。 +2. **复杂类型字段名被丢弃,引擎静默编造替代名**(trino 连接器构造 STRUCT 时不传字段名)。 +3. **通用扫描节点里仍有 ES 专有分支**——这直接违反「通用接口层禁止出现数据源专有代码」这条既定规则,而且同一个文件里自己写了这条规则、承接钩子也已经存在、ES 连接器还已经实现了那个钩子。 + +### C.5 两份文档的处置建议 + +**都保留。** 那份文档在若干处的事实核对比本文更精确(尤其是接口规模与写特性一节),本文在若干处推翻了它的结论并补出了它明示未查的三个包。建议在那份文档头部加一行指向本文,并按 C.2 列出的五处事实错误与三处结论改动就地修正——**不要用本文覆盖它**,两轮独立结论的并存本身就是可信度证据。 + +--- + +## 附录 D:完备性补检 + +前面 14 个审查单元有一个共同盲区:**它们审的都是「连接器实现的那一侧」**。补检单元把 100 个公共源文件与已确认结论做交叉,逐个读完了 19 个**完全没有被任何结论覆盖**的文件,并把「由引擎实现、连接器消费」的那几个接口当作一类单独审。结果如下。 + +### D.1 读路径存在一个改不掉的硬阻塞(比正文主题四写的更严重) + +正文主题四第 4.1 节(3)已并入这一条的结论:BE 侧的 JNI 分支本该是插件的通用逃生门,实测它**自己也是封闭的**(`be/src/exec/scan/file_scanner.cpp:1081-1174`,6 段按源名的 if-else,无 else、无「按参数给我一个读取器类名」的入口;原生读取链 `:1408-1530` 同样如此)。引擎侧把连接器给的格式串原样透传(`PluginDrivenScanNode.java:1793`),未识别格式在格式类型映射里落到 JNI(`:1972-1973`),于是走到 BE 的 JNI 分支、匹配不上任何源名、读取器为空、在 `:1326-1330` 抛「不支持为该表格式创建读取器」。 + +**结论**:「新增连接器不需要改公共模块」这句话在**读路径**上根本不成立——必须改共享 IDL + BE。这是根因性的(BE 是 C++ 且无插件机制),FE 侧的任何接口设计都消除不了它。文档必须明写这一点,否则第一个树外连接器作者会在跑通 `CREATE CATALOG` 之后才撞上它。 + +### D.2 由引擎实现的接口,默认值全是「静默降级」,而且今天已经有两个包装类漏转发 + +**这是补检发现的最有行动价值的一条。** + +`ConnectorContext` 的 19 个方法里**只有 2 个是抽象的**(取目录名、取目录 id),其余 17 个都有默认实现,而这些默认值的语义是:地址消毒原样返回(`:79-81`)、认证包装**直接跑、不套安全上下文**(`:98-100`)、失效通知返回空操作(`:109-111`)、**取文件系统返回 `null`**(`:391-393`)、取存储属性/BE 存储属性/broker 地址返回空、空目录清理什么都不做(`:412-414`)。 + +对比之下,同样由引擎实现的 `ConnectorValidationContext` 是 **6 个方法全抽象**,而连接器侧要实现的 `ConnectorProvider` 是 2 抽象 + 5 默认(方向正确)。**同一类接口三套不同政策,两个模块里没有任何成文规则。** + +**活证据(不是假设)**:iceberg 与 paimon 各有一个把线程上下文类加载器钉到插件加载器上的包装类,它们逐方法转发 `ConnectorContext`—— + +- `fe-connector-iceberg/.../TcclPinningConnectorContext.java:99-208` 只转发 **18/19**,漏了取文件系统; +- `fe-connector-paimon/.../TcclPinningConnectorContext.java:77-177` 只转发 **17/19**,漏了取文件系统与批量地址归一器。 + +漏转发**不会有编译错误**,包装类会返回接口默认值。**注意定性**:实测 iceberg 与 paimon **今天都还没有任何一处调用引擎文件系统**(hive 有 6 处直接调用、并经内部辅助方法扩散到约 19 处)。所以这是**潜伏缺陷,不是当前可观测的错误**——这两个连接器一旦开始用它,拿到的会是 `null` 而不是引擎文件系统。paimon 漏转发的批量地址归一器属于另一种性质:行为仍正确,只是退回逐次归一,是**静默的性能回退**。更要紧的是机理:**以后每往 `ConnectorContext` 加一个带默认实现的方法,这两个包装类都会静默丢掉那次类加载器钉桩**——正是本项目已经踩过并记录过的类加载器分裂事故类型,而编译器不会提示。 + +**建议**:把引擎必须履约的方法改成抽象(至少地址消毒、认证包装、取文件系统这三类安全与资源方法),或者让两个钉桩包装类改为继承公共模块提供的一个转发基类——一处补全,包装类不再逐个手抄。 + +### D.3 会话接口同病,且有一个默认值会静默关掉刚做完的性能优化 + +`ConnectorSession.getStatementScope()` 的默认值是「不做任何记忆」(`:142`)。任何没有覆写它的会话实现都会**静默关闭全部按语句的记忆化**——本项目刚落地的按语句去重优化会全部失效,且没有任何日志。 + +`getSessionId()` 默认回退到查询 id(`:42`),而它自己的文档说存在理由就是「跨查询稳定以复用一次认证的会话」——默认值恰好破坏了这个目的。`getSessionProperties()` 默认返回空表,于是所有由会话变量驱动的连接器行为静默走死默认。 + +成本侧证据:全仓 19 个会话实现,其中 18 个是测试替身,每个都要手写 8 个抽象方法。而抽象与默认的划分没有原则可循:取区域设置是抽象的(几乎无人真正需要),取语句作用域(正确性与性能关键)反而是默认的。 + +### D.4 新增一个只读连接器的真实最小成本(量化「可发现性」缺陷) + +按编译器强制的抽象方法实测,一个新连接器的**最小实现集**是: + +- 服务发现侧:取类型名、创建连接器(其余有默认实现;另继承一个语义上不存在的无参创建方法); +- 入口接口:**只有 1 个**(取元数据对象); +- 元数据接口及其 6 个子接口:**抽象方法 0 个**。 + +也就是说**编译能通过,但一行也不工作**。要真正可用,作者必须「自己发现」该覆写哪些——取表句柄、取表 schema(默认抛「未实现」)、取列句柄、列库名、列表名、判库存在。**这是「可发现性」缺陷的量化证据:46 个默认方法里没有任何机器可读的「最小实现集」标记,只能靠抄别的连接器。** 这也是正文建议第 3 批(按域拆父接口 + 每域写清最少实现集)真正要解决的问题。 + +扫描侧最小集是:一个分片生成方法(唯一的抽象方法)+ 一个分片实现,而后者强制实现两个方法,其中一个(分片类型)**已被证明是死表面**。 + +### D.5 两个「直觉上以为要改、实测不用改」的点 + +这两条值得明确写下来,否则会被当成漏检: + +1. **新连接器不需要任何持久化注册。** 所有插件目录/库/表共用同一套通用类的 Gson 子类型;`GsonUtils.java:362-506` 里全是「老类型 → 通用插件类」的**兼容映射**,不是新增点。新连接器零 Gson 改动、零镜像兼容负担。 +2. **标量类型映射不是封闭分支。** 引擎侧的类型反向构造在默认分支直接按名字创建 Doris 标量类型(`ConnectorColumnConverter.java:322-328`),所以新连接器可以直接用任意 Doris 标量类型名而**无需改引擎**。(先前只提到「拼错会静默退化为不支持类型」,这一半是好消息,应一并记下。) + +### D.6 补检出的其余问题 + +| # | 问题 | 位置 | 严重度 | +|---|---|---|---| +| 1 | **标识符映射只有「远端名 → 本地名」单方向**,反向映射事实上由引擎自己在缓存对象上承担,而 jdbc 连接器内部其实两个方向都实现了。这条关键分工既不在文档里,也不对称——任何需要「按本地名反查远端名」的新能力都会再在引擎侧造一次轮子 | `ConnectorIdentifierOps.java:37/49/63` | 低 | +| 2 | **`Serializable` 是零执行的假契约,且已被实现规避/违反**:四个公共类型声明了它,每个实现都被迫写序列化版本号;但引擎全树没有任何序列化这些类型的路径。而且 trino 的表句柄把真身字段声明为 `transient`(反序列化后必然不可用),hive 的表句柄持有一个**不可序列化**的类型(真序列化会抛异常)。建议删掉这个声明,或写清「引擎从不序列化,声明仅为将来预留」 | `handle/ConnectorTableHandle.java:25` 等 4 处 | 低 | +| 3 | **句柄家族的身份契约不对称**:列句柄用重声明 `equals`/`hashCode` 强制身份语义,表句柄什么都不要求——尽管表句柄会进语句作用域的键、跨 provider 传递、参与 EXPLAIN。另外具名列句柄是非 final 类而 `equals` 用 `instanceof` 判定,子类加字段后会与父类互等,破坏它所在接口强制的身份契约;透传查询句柄的构造器不校验查询串非空而 `toString()` 直接取子串(空值会抛空指针) | `handle/` 四个文件 | 低 | +| 4 | **库元数据对象里唯一被消费的是属性表里的位置字段,名字字段零消费**(调用方本来就传入库名)。这是第 4 处「结构化信息塞进属性表」。附带:子接口的默认实现返回空属性表,把「本数据源没有位置概念」和「位置为空」塌成同一态 | `ConnectorDatabaseMetadata.java:33/46-48`、`ConnectorSchemaOps.java:47-50` | 低 | +| 5 | **复杂类型的元数(arity)契约缺失**:数组需 1 个子类型、映射需 2 个、结构体 n 个,构造器不做任何校验;引擎对空子类型列表**静默产出 `ARRAY` / `MAP`**(无告警)。四组平行列表同样「越界即未设置」、无长度校验。(正文主题十一第 11.1 节(2)只覆盖了结构体字段名这一半) | `ConnectorType.java` 构造器、`ConnectorColumnConverter.java:242-249` | 低 | +| 6 | **表格式字符串的第三份定义在引擎侧**:一个 10 值的按源名枚举只服务遗留路径,插件路径根本不经过它;但 iceberg 连接器的文档反过来把这个**引擎私有枚举**当作自己返回值的权威词表。于是同一词表有三份定义(引擎枚举 / BE 的 if 链 / 各连接器字面量),公共模块一份都不持有 | `fe-core/.../scan/TableFormatType.java:20-30`、`IcebergScanRange.java:207-208` | 低 | +| 7 | **区间谓词未声明边界是否含端点**(SQL 的 BETWEEN 含端点);**分片源继承的关闭方法未说明谁负责吞异常**。都是一句文档的事 | `pushdown/ConnectorBetween.java:24-40`、`scan/ConnectorSplitSource.java` | 低 | + +### D.7 逐行读完、明确无问题的部分 + +避免这些被当成漏检,也为下一轮审查省时间: + +- **中立化做得最好的范例**:列类别枚举 + 列分类方法(iceberg 生产、引擎消费、引擎侧零连接器 import)。可作为其它部分中立化的参照。 +- `ConnectorColumnPath` / `ConnectorColumnPosition`:构造期就校验空路径与空片段、取父路径对顶层 fail-loud、位置只有两态且非空由工厂保证。是建表请求包里最干净的两个值对象。 +- `ConnectorMvccPartition`(「空上界=空值最小哨兵」的契约在类文档与取值方法双写)、`ConnectorWritePartitionSpec`(「`null` 而非空规格表示未分区」写在类文档里)、`ConnectorBrokerAddress`(正是「用中立值对象代替 thrift 网络地址」的正确做法)、`ConnectorSplitSource`(单遍/非线程安全/必须关闭三条契约都写了)、`ConnectorNot` / `ConnectorIsNull` / `ConnectorColumnRef`(非空校验与三个标准方法齐备)、时间旅行规格(封闭枚举一一对应公共 SQL 语法)。 +- `DorisConnectorException` 本身的写法无问题;它的缺陷是「缺错误分类契约」,正文主题十第 10.3 节已收录。 +- 空值安全比较在 iceberg / trino / es / maxcompute 四家都处理正确(含专门测试),只有 paimon 错——已在正文主题十一第 11.1 节(3)记录,并已用 `git show master` 对比确认是上游既有缺陷的移植。 diff --git a/plan-doc/connector-public-interface-cleanup/open-decisions.md b/plan-doc/connector-public-interface-cleanup/open-decisions.md new file mode 100644 index 00000000000000..f46f330b0868b7 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/open-decisions.md @@ -0,0 +1,222 @@ +# 需要拍板的决策清单 + +写 25 份任务文档的过程中,每份都在结尾提出了「动手前需要先定的事」。这里把它们汇总成一张表,避免开工后才发现某个选择会改变用户可见行为、或者两个任务的做法互相冲突。 + +**怎么用这份文档**: +- 大多数条目文档里已给出推荐值,**照推荐做就行**,不必逐条回答;这里列出来是为了让你知道「有过一次选择」。 +- 少数条目会**改变行为或用户可见文案**,下面用 ⚠ 标出,这些最好在动手前定下来。 +- 每条都对应到具体任务文档,细节与利弊分析在那里,不在这里重复。 +- 下面条目里的「我」指那份任务文档的作者(写文档时做过一次调研与取舍),「你」指做决定的人。 + +## ✅ 已拍板(2026-07-25,第一批落地时) + +1. **含隐式类型转换的谓词下推默认值**:**只改文档,默认值不动**(选项 B)。两条路径的差异、正向转换器拆壳、 + 「风险由连接器承担」已写进 javadoc;默认值翻转另开一项,需逐连接器判断 + 端到端回归。 +2. **最少实现集用什么形式钉住**:**注解 + 冻结测试**(原方案)。`@ConnectorMustImplement` 标在方法上, + `ConnectorMetadataSurfaceTest` 冻结 81 条方法签名基线并断言「无条件必须的恰好是那 5 个」。 + **代价已接受**:后续每一批删死接口都要在同一个提交里重新生成基线。 +3. **调研期发现的两个真实用户可见缺口**(异构目录嵌套列 DDL、iceberg 表注释为空):**本轮顺手修掉**, + 已落地并配单测;e2e 用例已写出,待有集群时执行。 + +## ✅ 已拍板(2026-07-25,第一批死接口面落地时) + +4. **连接器自声明属性:接线还是删除**(24 号主决策):**删除**(选项二)。已随 11 号落地,并按该文档 + 5.2 的附带条件在 `fe-connector-api` 的包级说明里新增「规则七——连接器的旋钮该放在哪」,写清三条真实 + 通道(按目录 / 按 FE 进程 / 按会话)、键名前缀约定,以及「这里刻意没有描述符机制,别去找」。 + 24 号与 11 号的去重按「在 11 号里落地」执行。 +5. **建表请求的 `isExternal`**(11 号):**删除**(选项 A)。中途曾选「保留 + 让 hive 真正消费」,核实后 + 改回删除——理由是它在任何能到达连接器的路径上都是编译期常量 `true`(`CreateTableInfo.checkEngineName` + 强制置真),而 hive 侧今天硬编码 `MANAGED_TABLE`(与上游 master 逐字相同)。让 hive「真正消费」一个恒真 + 的位,等于把所有 Doris 建的 hive 表改成外部表、`DROP TABLE` 不再删数据——那是行为变更,不是补文档, + 不该混进一批零行为变化的删除里。若将来真要区分托管表/外部表,走已有的建表 PROPERTIES 通道。 + +## ✅ 已拍板(2026-07-25,删目录类型白名单时) + +6. **插件与内建目录类型的路由优先级**(15 号主决策):**插件优先 + 内建类型名成为保留字**。插件优先保住现有七个类型行为逐字不变;同时把 `doris` / `test` / `lakesoul` 设为保留字,插件声明这三个名字时在**注册期**就被拒绝(类路径批次抛异常=构建错误,插件目录批次记 error 并跳过=保住「一个坏插件不阻止 FE 启动」)。这比文档原方案(接受「插件可以遮蔽内建类型」)更强,参照 Trino 的单一注册表 + 重名直接拒绝。代价:约 15 行 + 一条防止保留字清单与内建 switch 走偏的单测。 +7. **建目录失败的报错是否列出已注册类型**(15 号):**列出**。可诊断性明显更好(拼错类型名与插件未安装从此可区分),Trino 同类报错也列可用连接器;已知代价是把已安装插件清单暴露给持有目录级 CREATE 权限的非管理员用户,判定为可接受。清单**过滤掉非独立类型**(如 hudi),否则会把一个建不出来的名字列给用户。 +8. **删掉私有字段后残留的 20 处注释**(15 号):**主改动一个提交、纯注释清理另一个提交**。功能改动只碰承载真实契约的 4 处以保持 diff 可评审,紧接一个纯注释提交把其余 15 个文件的悬空引用改成中立描述,并删掉其中 6 处**本轮之前就已经错了**的 pre-cutover 说法。 + +## ✅ 已拍板(2026-07-25,下推契约 + 三批删除落地时) + +9. **读侧的主键接口**(12 号,此前长期悬置):**删除两条通道**。`getPrimaryKeys` 只有 jdbc 实现、引擎零调用; + `ConnectorTableSchema.PRIMARY_KEYS_KEY` 只有 paimon 写入、而 fe-core 会把整族保留键从 `SHOW CREATE TABLE` + 剥掉——写进去就被删掉,没有第三个消费者。**不动的三处**:paimon 写给用户看的 `primary-key` 表属性、jdbc 连接器 + 内部客户端层的同名方法(连接器内部 JDBC 元数据封装,含 MySQL 的 KEY_SEQ 重排)、流式作业读主键走的 fe-core + 旧 JDBC 客户端。 +10. **下推契约里的可选公共工具方法**(09 号):**不加,做成纯文档**。上一批修 LIKE 丢行时已经在 paimon 内部落了 + 一个私有守卫,所以「加公共工具方法」现在等价于「把私有守卫上提成公共 API 并让 paimon 改用」;其余连接器都不 + 需要它(ES 自己做转义、jdbc 原样拼进远端 SQL),本轮主线又正是在删「只有一个或零个消费者」的公共接口面—— + 一边删一边加会自相矛盾。规则写进包级说明与 LIKE 类注释即可。 +11. **hudi 的 `partition_values()` 新鲜度**(12 号):**只据实改注释与测试,不改缓存路由**。它经 `listPartitions` + 读缓存,因此可能落后一个缓存过期时间(`SHOW PARTITIONS` 走取最新的那条路,不受影响)。这是删除动作揭出来的 + 既有行为;要不要改成绕缓存取最新是独立一项(见下面「顺带发现」)。 +12. **引擎侧那条同样说反的注释**(09 号顺带):**一并改**。`getScanNodePropertiesResult` 的文档说反了默认语义 + (说「默认等于声明所有谓词都已下推」,实际默认不摘任何 conjunct),而 fe-core 的 + `pruneConjunctsFromNodeProperties` 注释是同一处倒置的镜像。只修一半会让下一个人从另一半读回错的结论; + 纯注释、零可执行代码改动,不违反「fe-core 只出不进」。 +13. **hive 的死字段怎么收**(12 号):**删字段与赋值、保留全部 31 处构造签名**,最宽构造器因此留一个未用形参。 + 为消一个形参去动 31 处构造点,风险大于收益。 +14. **分片属性表里的格式键与「零必须实现方法」**(13 号两条排除项):**都不做**。`connector_file_format` 牵动 + 「扫描级格式类型决定 BE 走哪代读取器」这条已知风险线;把 `getProperties()` 降成默认实现会让 jdbc 那条唯一走 + 默认参数填充的活链路失去编译期强制(忘实现就在运行期缺 `jdbc_url`,而不是编译失败)。 +15. **iceberg 测试里 7 处「连接器没有走推模型通知引擎」的断言**(14 号):**删掉**。删掉接口后其失败条件在类型 + 层面不可能出现,永远不会红的断言比没有断言更糟;设计意图保留在类注释里。 + +## ✅ 已拍板(2026-07-26,属性键契约 + 按源名分支中立化落地时) + +16. **查询剖析里两行恒为 `N/A` 的条目**(16 号第四处,`Iceberg Scan Metrics` / `Paimon Scan Metrics`): + **删掉**(用户选「删掉(推荐)」)。这两个常量全仓库没有任何赋值点,而 `SummaryProfile.init()` 会给每个 + 已登记的键无条件塞一条 `"N/A"`,所以**每一条查询**(包括纯内表 `count(*)`)的执行摘要里都多出这两行; + 真正的 iceberg/paimon 扫描指标是以**同名子节点**的形式挂上去的,于是「一行空条目 + 一棵同名子树」并存, + 比只有其中一个更容易看错。是**用户可见变化**(少两行),但属修正而非回归:没有任何代码路径能填上它们, + 且 `regression-test/` 与 `docs/` 零引用。代价:外部脚本若按行位置硬解析执行摘要,会看到少两行。 + 连带把连接器侧「分组名必须与 fe-core 常量一致(用于显示排序)」那两句错注释改对——那个耦合从来不存在。 + +## ✅ 已拍板(2026-07-26,中立化三项落地时) + +- **20 号|空分区哨兵的中立常量名**:改成 `NULL_PARTITION_NAME`(值 `__HIVE_DEFAULT_PARTITION__` 一个字节不动),**不保留过时别名**。理由:它是编译期常量,树外连接器的字节码里早已内联了字面量,别名只对源码重编译有意义,却会把那个品牌名永久留在中立模块里,还让引擎剩一处引用一直报过时警告。 +- **20 号|hudi 的 `\N` 与 hive/paimon 的空串不在本轮统一**:确认划界。(本轮另有实证:BE 在空值位为 true 时**根本不读**那个字符串,所以统一是安全的,只是需要端到端确认——见 HANDOFF 的待办项。) +- **19 号|EXPLAIN 与实际下推的判据不一致逐字保留**:验收基线=EXPLAIN 文本不变,加 ATTN 注释 + 单独立项。理由:修它会改用户可见输出,而验证它需要 ES 集群,不该混进"行为不变的重构"。 +- **19 号|REST 直通接口做成通用路径透传**(`executeRestRequest(path, body)`),不做成 `getMapping` / `search` 两个具名方法。理由:具名版会把两个 ES 词汇的动词写进本该中立的公共模块;端点侧的路径拼接与安全面单独立项处理。 +- **19 号|那两个 HTTP 端点的既有安全面记录并单独立项**(表名不校验直接拼进 URL、无目录级权限检查、口令检查只在某全局开关打开时才做),本批不动。 + +--- + +## ⚠ 必须先定的四件事 + +1. **含隐式类型转换的谓词下推默认值**(08 号):`supportsCastPredicatePushdown` 目前默认 `true`,而它承诺的「引擎会先剥掉类型转换」只对残余谓词那条路径成立。改成默认 `false`(安全侧)等于跨六个连接器的行为改动,会在残余谓词路径上少推一些谓词。三个选项见 08 号文档 5.1。 +2. **连接器自声明属性:接线还是删除**(24 号):影响 FE 配置项的用户可见位置,涉及兼容承诺。文档推荐删除并把入口形状记进设计文档,理由是接线也解决不了真实卡点。 +3. **建表能力下沉后有三类组合的报错文案会变**(18 号):改前改后都是拒绝,只是拒绝点提前。要么接受文案变化,要么在下沉时保留原文案。**另外 18 号还纠正了一个会误导施工的事实:仓库里根本不存在分桶子句的正向端到端护栏**,这个任务必须自己补一条。 +4. ~~**插件与内建目录类型的路由优先级**(15 号)~~ **已拍板并落地,见上面第 6 条。** + +## 其余条目(按任务编号) + +### 01 修复 trino 连接器对三路以上 OR 谓词只取前两支导致的丢行 + +1) 是否接受把 `ConnectorAnd` 的同类改动(浅包裹 → 真拷贝 + 至少两个校验)排除在本次提交之外?文档按「排除」写,理由是它今天所有生产者都已守住 ≥2、无实际风险,混进来会稀释一个正确性修复的变更意图。若希望一次改完,把 5.3 的第一条移进 5.2 即可。2) 新加的构造器校验是硬抛 IllegalArgumentException(单分支 OR 视为调用方逻辑错误,要求当场暴露)。如果更偏保守(担心某条冷路径在生产上因此抛错),可改成「降级为返回唯一分支 + WARN 日志」,但那会让契约重新变成软约束——需要拍板取哪一种。3) 端到端回归是否要求本任务同步补进 external_table_p0/trino_connector 的现有用例(需要 docker 环境实跑),还是允许只交单元测试、e2e 与其它三条缺陷修复一起统一补。 + +### 02 修复 paimon 连接器把「空值安全等于」下推成 IS NULL 导致的丢行 + +1)空值字面量那一半要不要一起做?最小正确性修复只需「非空字面量 → equal」;额外把 `c <=> NULL` 下推成 IS NULL 是新增裁剪能力(与 iceberg 对齐、语义安全,因 isNull 只依赖空值计数统计与类型无关)。文档给了推荐方案(两半都做)与最小方案(只做非空那半),需用户拍板取哪个。2)要不要补 paimon 端到端回归用例?补的话用例里必须 `set disable_nereids_rules='NULL_SAFE_EQUAL_TO_EQUAL'` 才压得到连接器路径,需要 docker 环境;文档现列为可选、不阻塞合并。3)本文档刻意不改 ConnectorComparison 的契约文字(留给「下推表达式契约补全」任务),如果用户希望两件事一次提交完成,需要明确合并顺序以免同一行冲突。 + +### 03 修复 paimon 连接器把含单字符通配符或转义的 LIKE 收窄成前缀匹配 + +1) es 那处 REGEXP 锚定语义不一致是否与本任务同批修?文档已按「由排期决定、本任务不做」处理,需用户拍板。2) 同一个文件里的空值安全比较缺陷(`列 <=> 5` 被下推成 IS NULL)是另一份任务,是否与本任务并到同一次端到端回归里跑?文档给了建议但未替用户决定。3) 修复后 `_`/转义/中间 `%` 三类模式串不再裁剪文件,会多读数据——这是「快但错」换「慢但对」,方向上不认为有争议,但若有对 LIKE 性能敏感的场景需用户知晓。 + +### 04 修复 trino 连接器丢弃复杂类型字段名导致引擎编造 col0 / col1 + +1) 匿名 ROW 字段的命名:文档选择 `col` + 下标(与引擎兜底及其它四个连接器一致),有意不复刻迁移前「所有匿名字段都叫 col」的重名行为——这是唯一一处刻意的行为偏离,若你要求严格 legacy parity 请拍板改回。2) 新校验对「非复杂类型标签带了子类型」不做检查(typeName 是无词表裸字符串,不能反向断言),是否接受这个收窄。3) fe-core 侧 ConnectorColumnConverter 的三处静默降级分支(col+下标、ARRAY、MAP)文档建议零改动保留为防御(fe-core 只出不进 + 改抛会把单列退化升级为整表加载失败),若你希望它们也 fail loud 需单独立项。4) 是否要求本任务必须补 trino 的 STRUCT 端到端用例(现状零覆盖),还是接受仅单元测试验收。 + +### 05 修复 hudi 在「最后修改时间」字段里填时刻串导致查询缓存永久失效 + +1) 时区处理要不要按我推荐的「从表配置 getTimelineTimezone() 读」来做(多两个方法、零额外远程调用、消除显式 UTC 表的小时级偏差),还是接受一行的 HoodieInstantTimeGenerator.parseDateFromInstantTime(更简单,但按 FE 全局时区解析)。我在文档里已选前者,若你偏好极简可以改。2) instant 解析失败时的策略:我选了 log warn + 返回 0(不炸查询热路径),这与仓库里同一条路径上 requestedTimeToInstant 的 Long.parseLong 「fail-loud」风格不完全一致,是否接受这个不一致。3) 端到端用例放 p2(hudi 环境在 p2)意味着它不进 p0 常规回归,只在跑 hudi 外部环境时验证;是否需要额外在 p0 加一条不依赖 hudi 环境的替代守卫(目前靠单测的量级断言承担)。 + +### 06 补齐两个连接器对引擎上下文的转发缺口,并根治「加一个方法就漏一次类加载器钉桩」的机理 + +1) 转发基类叫 ForwardingConnectorContext 并放在 fe-connector-spi(org.apache.doris.connector.spi),等于给公共模块新增一个公共类。这条工作线的目标是「新增连接器不必改公共模块」,新增基类方向一致,但仍是公共表面 +1,请确认接受;若不接受,退路是只手工补齐两处漏转发(不根治机理,以后每加方法仍会漏)。 +2) 基类只保证「不丢转发」,不保证「不丢钉桩」——将来新增的方法若会进入插件代码,仍需钉桩子类显式覆写。文档把这条残留风险写进基类注释与单测失败提示。是否接受这个边界,还是希望更强的做法(例如默认全部方法都钉桩、只对明确不需要的方法 opt-out)?后者会改变现有行为并带来无谓开销,我不推荐。 +3) 任务 14(删除推模型失效接口)也要改这两个包装类,文档已建议 06 先做、14 后做。若排期上想反过来,需要在 14 里重做一次同样的迁移。 + +### 07 把公共模块的设计规则写下来(两个模块各一份包级说明) + +1) 包级说明正文用英文还是中文:文档已按仓库既有 javadoc 惯例(fe-connector-cache/package-info.java、全部接口 javadoc 均为英文,且这条线最终要进上游 apache/doris)定为英文,中文只出现在本施工说明书里。若用户希望包级说明写中文,需要在动手前拍板。2) fe-connector-api/pom.xml:35-38「Consumer-facing API」的修正不在种子明确列出的范围内(种子只点了 spi/pom.xml:38 的 ConnectorTypeMapper),我按「规则四要说清模块名与内容倒置」把它一并列入改动清单(一行描述、零风险);如希望严格限定范围,可从清单里去掉。3) 规则二的异常四分类是否要落成真实的异常子类(如 DorisConnectorUserException 等),本任务按「只写规则不加类」处理(加子类会牵动全部连接器的 throw 点与引擎 catch,属独立任务);若用户希望本轮就建子类层次,需另立任务并重排依赖。 + +### 08 修正与实现矛盾的接口文档(一批) + +1) `supportsCastPredicatePushdown` 的默认值怎么办(必须拍板,文档 5.1 给了三选项):A 默认翻 false(对齐 opt-in 纪律,但等于跨 iceberg/hive/hudi/es/trino/hms 六个连接器的行为改动,会在残余谓词路径少推谓词);B 本批只改文档、默认值不动、翻转另开一项配端到端回归(我推荐 B);C 引擎在拆壳处打标记让连接器能自查(要在公共接口加表达能力,远期)。 +2) 三处与「删掉没有调用方的接口面」任务重叠的符号(listPartitionValues、ConnectorScanRangeType 一族、getCurrentEventId):确认按「删除任务处理、本任务不改文档」执行?若你决定保留其中任何一个,请告知,我在 5.2 已给出对应的文档改法。 +3) 六处裸工单号 `#65329` 是否要清(改成行为描述 / 写全 apache/doris#65329 / 原样保留)?它是上游公开编号,不属于内部符号泄漏,故列为可选项。 +4) 可选的 hive 契约测试正样本(补一行 validate 调用)要不要做?如果 hive 连接器在无元存储的单测环境里构造不出写计划提供者,我的处置是不硬凑、只在 javadoc 里记下缺口——确认可接受? + +### 09 补全下推表达式的契约(逐算子语义 + 不可精确翻译必须放弃下推) + +**已完成(2026-07-25)。** 1) 与 2) 已拍板见上面第 10、12 条;3) 按「如实记录不统一」执行。原文保留以备回溯: +1) 可选工具方法要不要做:文档给的是「只加一个 ConnectorLikePatterns.exactPrefix,EQ_FOR_NULL 不加工具方法」的最小方案;若希望零新增 API(纯文档任务),可整块摘掉,其余不受影响。2) 与任务 07、08 的文档分工需拍板确认:本文约定 pushdown 包内文档 + ConnectorScanPlanProvider/ScanNodePropertiesResult 上残差协议相关那几行归 09,其余陈旧文档归 08;若 08 的清单已包含这几处,请以先落地者为准并划掉重复项。3) 列名大小写规则目前各连接器自行决定(paimon 强制小写、jdbc 走映射表),本任务只如实记录不统一;要不要统一是行为决策,需另开任务并拍板。 + +### 10 把 ConnectorTableOps 按域拆成父接口,并为每域写清最少实现集 + +1) 域的个数:要不要把系统表三方法(listSupportedSysTables / getSysTableHandle / isPartitionValuesSysTable)从「表基础」里再拆出第 7 个域?拆了「表基础」从 14 降到 11、最少实现集更干净;不拆则与调研建议的 6 域一致。我按 6 域写。2) executeStmt / getColumnsFromQuery 暫留聚合接口是否可接受?替代方案是本任务就把它们摘成可选接口,但那会牵动 jdbc 连接器,本任务就不再是零破坏。3) 「方法集合冻结」测试的基线文件在后续删死接口批次里必然要改(那是故意的减速带),是否接受这个维护成本?若不接受,可退化为只断言方法数量与「每个域接口的方法名集合」两条较松的断言。4) 标记注解取名 ConnectorMustImplement(含 when() 说明触发前提)是否合意,还是更偏好统一的 javadoc 标签而完全不引入新类型? + +### 11 删除第一批死接口面(公共模块内部,不动连接器生产代码) + +**已完成(2026-07-25,5 个提交)。** 下面这条已拍板为「删除」,保留原文以备回溯:ConnectorCreateTableRequest.isExternal 的处置——选项 A 直接删(建议)/选项 B 保留但必须同时补类文档说明「external 在连接器语境下的确切含义」并让至少一个连接器真正读它改变行为(如 hive 决定写 EXTERNAL_TABLE 还是 MANAGED_TABLE)/选项 C 维持零消费零文档(不允许)。 +另外一提:另一条同性质判断题 getPrimaryKeys(含 ConnectorTableSchema.PRIMARY_KEYS_KEY)要改 paimon 生产代码,不在本批,留给「需连带改连接器」那批一并拍板。 + +### 12 删除第二批死接口面(需要连带修改连接器) + +**已完成(2026-07-25)。** 四条全部拍板:1) 见上面第 13 条;2) 见第 11 条;3) 保留 jdbc 客户端层方法;4) 见第 9 条(删)。原文保留以备回溯: +1) hive 的 properties 死字段怎么收:推荐「删字段+赋值、保留全部构造签名(最宽构造器留一个未用形参)」,代价是留一个未用形参;若评审更在意形参整洁,则要动最宽构造器签名及其调用点(构造点共 31 处,需另做一笔独立改动)。 +2) hudi 的 partition_values() 新鲜度:今天走缓存,可能落后一个 TTL。要不要改成绕过缓存取最新?本任务不改,只把注释与测试改成据实描述。 +3) jdbc 连接器内部三处 getPrimaryKeys(client 层)删掉 SPI 覆写后暂无调用者:保留(推荐)还是同批清理?清理会牵动 MySQL 的 KEY_SEQ 重排与 OceanBase 委派,属连接器内部事务。 +4) 调研报告 7.4 节把 getPrimaryKeys 标为「判断题不是事实题」:本文按「删」写。若最终决定保留主键接口,则必须同时补契约文档并让至少一个连接器真正消费它,不允许维持零消费+零文档 —— 这一点需要在动手前确认口径。 + +### 13 删除分片类型枚举族(本轮最有价值的一条删除) + +**已完成(2026-07-25)。** 两条排除项均按「不做」拍板,见上面第 14 条。原文保留以备回溯: +1) getProperties() 是否也降为带默认实现(返回空表)——文档给出的建议是「本任务不做」,理由是收益仅 5 处、代价是 jdbc 那条唯一走默认 populateRangeParams 的链路失去编译期强制;如果你希望把 ConnectorScanRange 做成「零必须实现方法」的接口,这一项需要你拍板,并应作为独立提交。2) 是否顺带清掉同样落在 jdbc_params 里、BE 也不读的 connector_file_format 键(连带 ConnectorScanRange.getFileFormat() 这条表面)——我按「不扩大范围」处理成排除项,因为它牵动扫描级格式类型这条已知风险线;若你希望一次删干净,可把它并入本任务或单开一条。 + +### 14 删除推模型的缓存失效接口,让「失效」只剩一个方向 + +**已完成(2026-07-25)。** 1) 06 号先落地,本轮照删(转发已收到公共基类,删除点是那一处而非两个连接器);2) 见上面第 15 条;3) 旧计划文档的作废说明随本轮文档提交补。原文保留以备回溯: +1) 顺序拍板:本任务与编号 06(补齐上下文包装类转发缺口)是「先删后补」还是合成一个提交?文档默认建议先删本任务。 +2) IcebergProcedureOpsTest 里 7 处 ctx.invalidatedTables 断言,文档主张删掉(删接口后其失败条件在类型层面不可能出现,留着就是永不会红的测试),设计意图保留在类注释里——若你更希望保留某种形式的守护断言,需要另行指定形式。 +3) 早期计划文档(00-connector-migration-master-plan.md、01-spi-extensions-rfc.md、decisions-log.md 的 D-004)里仍写「HMS 事件管线通过这个接口回调」,文档建议补一句作废说明而不改写历史进度记录——是否照此处理由你确认。 + +### 15 删除目录类型白名单,让注册了插件的连接器真的能被路由到 + +1) 路由顺序:文档采纳「插件优先于引擎内建类型」(保持现有七个类型行为逐字不变),代价是第三方插件可以遮蔽 `doris`/`test`/`lakesoul` 这三个内建类型名。若你更希望内建类型不可被遮蔽,需要改成「内建优先」并在 getType() 契约里把这三个名字列为保留字——请拍板。2) 插件目录批次撞名的处置:文档选「跳过后来者 + LOG.error」以保住 loadPlugins 既有的「部分成功、不阻塞 FE 启动」契约;类路径批次选「抛 IllegalStateException」。若你认为撞名严重到应当一律阻止 FE 启动(宁可起不来也不要不确定的胜者),需要改口径。3) 类型冲突检测建议抽一个包内可见的登记方法以便直测——这是为可测性引入的一个小接缝,若你不接受在 fe-core 增加这个接缝,则该条只能靠伪造 META-INF/services 来测(不推荐)。4) 建目录报错文案里要不要列出「当前已注册类型」清单:这会把已安装插件列表暴露给任何能执行 CREATE CATALOG 的用户,可诊断性与信息暴露的取舍请确认。 + +### 16 把引擎里三处按数据源名判定的软阻塞分支改成中立声明 + +1) `supportsFileCache()` 这个方法名要不要换(候选:`participatesInFileCache()` / `readsWithNativeFileReader()`)——语义是「数据由 BE 原生文件读取器读取、BE 文件缓存对它有效」,用 supportsXxx 前缀是为了对齐既有 supportsBatchScan / supportsTableSample 的约定,但名字本身不如后两个候选自解释。2) paimon 的文件缓存声明:今天白名单里有 `paimon`,但 paimon 的部分表走 JNI 读取(`supportsTableSample` 的 javadoc 明确说 Paimon JNI ranges 长度为 -1)。本文按「零行为差」要求 PaimonScanPlanProvider 声明 true 以保持现状;如果 owner 认为「JNI 读取不该做准入判定」,那这次迁移就顺带修一个既存的误覆盖,需要拍板是保现状还是改语义。3) 四处是否拆成 4 个独立提交(本文建议拆),以及第四处(删剖析常量)要不要单独走一次评审——它会改变所有查询的剖析输出行。 + +### 17 把按表能力从字符串升级为类型化集合,并删掉写特性的镜像方法 + +1. 写特性镜像方法的删除范围:文档按 11 个(含 2 个 supportedWriteOperations)来写,调研报告口径是 9 个。若坚持只删 9 个,入口接口上会残留同一种镜像,需要用户确认取哪个口径。 +2. 让 iceberg-on-HMS 表继承 SUPPORTS_SHOW_CREATE_DDL / SUPPORTS_VIEW / SUPPORTS_METADATA_PRELOAD 是不是想要的改进?文档把它明确排除在本任务之外(列进「不要顺手做」,理由是真实行为变化需独立评审 + 异构目录端到端回归)。若用户认为这本身就是要兑现的能力,应作为独立任务排期。 +3. 「fe-core 只出不进」的判定:文档在 PluginDrivenExternalCatalog 上新增一个通用访问器 hasConnectorCapability,用来替掉 4 个类里重复的样板判断(整体净删行、非数据源相关代码)。若用户认为这仍属「往 fe-core 加东西」,可退化为各调用点原地保留直读,只做类型化那部分。 + +### 18 把建表能力从引擎白名单下沉为连接器声明(高风险,需端到端兜底) + +1) `analyzeEngine` 的判据从「引擎名」换成「目标目录的能力位」后,有三类组合的**报错文案会变**(改前改后都是拒绝,只是拒绝点提前):内部目录写 `ENGINE=hive` 且带 `PARTITION BY`;jdbc 目录写 `ENGINE=hive` 且带 `PARTITION BY`;非插件的 `doris`/`test` 目录建表。文档给的方案是「实测确认没有用例依赖旧文案就接受这三处文案变化」,若您要求**绝对零文案变化**,则需要在非插件目录场景保留一份封闭的旧引擎名清单(更丑但零变化)——请拍板选哪种。 +2) 新增能力位命名建议为 `SUPPORTS_CREATE_TABLE_PARTITION_BY` / `SUPPORTS_CREATE_TABLE_DISTRIBUTED_BY`(与既有 `SUPPORTS_SORT_ORDER` 同族、都是建表子句门);如果您更偏好与既有短命名一致的 `SUPPORTS_PARTITION_CLAUSE` 之类,需在动手前定名,之后改名要动 4 个连接器。 +3) 建表引擎名声明放在 `Connector`(实例级,需要不强制初始化的读法)还是放在 `ConnectorProvider`(类型级,天然不需要初始化)?文档推荐 `Connector`,理由是与既有能力位读法同构、且同类不强制初始化的读法已有两处先例;若您希望彻底避开「分析期触碰目录初始化」,可改放 provider,但 fe-core 需要新增一条按类型查 provider 的通路。 + +### 19 把 Elasticsearch 专有的逃生门与通用扫描节点里的 ES 分支归位 + +一个:EXPLAIN 侧缺少 `limit <= batch_size` 判据,会让 batch_size 较小时用户看到一个并未真正生效的 `ES terminate_after`。文档默认逐字保留这个既有不一致(验收基线=EXPLAIN 文本不变),是否允许在搬迁的同一轮里把连接器侧 EXPLAIN 判据补齐成与实际下推一致(代价是 batch_size 小于 LIMIT 时 EXPLAIN 输出会变;现有 p2 用例 limit 5/3 远小于默认 batch_size,不会变红)?已写入第七节。 + +### 20 分区空值哨兵的中立化命名与归一方法下沉 + +1) 中立常量叫什么:文档草案用 NULL_PARTITION_NAME(因为原来占着 NULL_PARTITION_VALUE 名字的 "\\N" 会随方法一起下沉到 hudi,名字腾出来了,不会混淆)。备选 DEFAULT_PARTITION_NAME。请拍板一个名字,否则实施时会反复改 import。 +2) 是否真的要保留 @Deprecated 旧名别名一轮:本仓库内所有引用都会在本任务里一次改完,别名只对「仓库之外按旧名编译的第三方连接器」有意义。如果 fe-connector-api 还没作为公开 API 发布过,可以直接删旧名、少一个过时符号;发布过就按文档保留一轮。 +3) hudi 的空值渲染 "\\N" 与 hive/paimon 的空串是否要统一(BE 在空值位为 true 时理论上忽略该字符串):文档明确列为「不要顺手做」,若要统一需单独立项并配端到端验证。请确认这个划界。 + +### 21 把扫描节点属性表的键契约集中到公共模块 + +1) `hive.text.` 这个源专属前缀名:本文按「符号名中立化(TEXT_PROPERTY_PREFIX)、字面量保持历史值不变」处理,把真正改值(→ `text.`)排除在外。若您认为「通用层不出现源专属符号」要做彻底、连字面量一起改,请拍板——那需要引擎与 hive 同步改并配 hive 文本/CSV/JSON 端到端回归,风险从「结构调整」升为「有行为风险的改名」。2) 删除 `FileQueryScanNode.getSerializedTable()` 与其 :472 调用(fe-core 纯减 3 行)是否纳入本任务:纳入则树内无残余死钩子,不纳入则 fe-core 留两处死代码。文档按「纳入」写,若您偏好更小改面可拆出。3) 常量类的类名:文中用 `ScanNodePropertyKeys`;若您希望与既有命名(如 `ConnectorPartitionValues`)对齐成 `ConnectorScanNodeProperties`,请指定,动手前改名成本为零。 + +### 22 把分布式过程的结果列定义交还连接器 + +1) 汇总放在哪一侧:本文按「引擎对连接器给出的每组数字做直和后交出四个总数」定稿(理由:改动最小,求和三行本来就在驱动器 :178-180;且统计对象字段与 ConnectorRewriteGroup getter 同名,映射一目了然)。另一个可选形态是把 List 连同新增文件数一起交给连接器、由连接器自行聚合(好处是连接器可选非求和的聚合方式,坏处是把编排对象塞进结果 API)。若用户偏好后者,5.1/5.2 需相应调整。 +2) 新增类与方法的命名是否照批:ConnectorRewriteStatistics / ConnectorProcedureOps#buildRewriteResult。命名刻意避开数据源词,但「rewrite」是否算中立词(本文按「合并重写操作模型的中立词」处理,并在 5.3 明确不重命名既有 ConnectorRewriteDriver / planRewrite / RewriteCapableTransaction)需确认。 +3) 是否顺手给端到端用例补一条列名断言(6.3 可选加强)。补了以后列名从此有端到端护栏,但会让本任务多碰一个 groovy 文件;本文默认不补,等用户拍板。 +4) IcebergRewriteDataFilesAction 是否要同时覆写 getResultSchema()(今天不可达,纯为与八个单次调用兄弟保持自描述一致)。本文建议覆写,属可去掉的 3 行。 + +### 23 把引擎上下文里的存储服务收成独立服务对象(高危) + +1) 引擎侧实现方式:文档推荐让 DefaultConnectorContext 同时实现 ConnectorContext 与 ConnectorStorageContext、getStorageContext() 返回 this(零逻辑搬迁、fe-core 只加约 6 行、6 个 fe-core 单测零改动)。如果用户认为「独立服务对象就该有独立实现类」,需要接受把 300 多行含锁的文件系统缓存与 close() 生命周期搬进新类的额外风险——请拍板取哪一种。 +2) 改名后的方法名:文档定为 sanitizeOutboundUrl(调研报告备选 sanitizeRemoteUrl / validateOutboundEndpoint)。若用户偏好其中另一个,改一处即可。 +3) CURRENT_API_VERSION 是否递增(见 unverified 第 5 条)。 +4) 本任务与任务 14 的先后:两者都改转发基类但互不冲突,文档只要求「不要合在同一个 commit」,未指定先后;如需固定顺序请指示。 + +### 24 待拍板:连接器自声明属性——接线还是删除 + +1) **主决策**:选项一(接线成声明式属性)还是选项二(删掉三个死接口并把入口形状写进设计文档)。文档给出的推荐是选项二,理由三条:该机制即使接线也不解决真实卡点(值住在 fe.conf、无按会话覆盖语法);它现在挂错位置,接线等于重做设计,留着不省事;它挡住的唯一真缺陷(目录属性拼错键静默失效)有成本低得多的现成入口 ConnectorProvider.validateProperties。 +2) 若拍选项一,是否接受我建议的拆分:本次只做「目录级描述符 + 未知键默认只告警」,把 7 个 fe.conf 键搬迁与「SET SESSION 目录.属性」语法各自单独立项。 +3) 与 11 号任务的去重由谁承担:本文选项二与 11 号任务改动清单第 1 项是同一次删除,需要指定在哪个任务里落地,另一个标注为「已由对方完成」。 +4) 两个已记录但本文明确不做的独立小项,是否要各自立任务:把 max_compute_write_max_block_count 从会话通道搬回环境通道(它是 fe.conf 字段却走了会话属性通道,与 ConnectorSession.getSessionProperties 的 javadoc 冲突);以及给目录属性加「未知键告警」(无论主决策选哪条都可独立做)。 + +### 25 修正同日另一份评审文档里的事实错误与结论 + +1) 那份文档里被推翻的条目,是「就地改写成修正后的事实」还是「保留原文 + 追加一行『经交叉核对推翻』」?我按前者写(更省读者力气),但对第 589-591 行那种纯转述条目给了删除/留说明两种选项,需要拍板一种以保持全文一致。2) 修正标记用什么字样:我建议统一后缀「(交叉核对修正)」,如果你希望文档看起来干净不留痕,可以改成只在头部说明「本文已按交叉核对结果修订,逐条见任务空间报告附录 C」。3) hive 的契约测试调用点要不要顺带在这一轮补上(会引入 Java 改动、需要全反应堆含测试源 test-compile 验收)?我按「不顺手做」处理,留给代码任务。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/01-fix-trino-or-predicate-row-loss.md b/plan-doc/connector-public-interface-cleanup/tasks/01-fix-trino-or-predicate-row-loss.md new file mode 100644 index 00000000000000..259c830c686109 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/01-fix-trino-or-predicate-row-loss.md @@ -0,0 +1,221 @@ +# 01. 修复 trino 连接器对三路以上 OR 谓词只取前两支导致的丢行 + +> **优先级**:第一优先级(正确性缺陷,会静默少行) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe-connector-trino`(主修)、`fe-connector-api`(构造器加校验与防御性拷贝) +> **预计改动规模**:2 个生产文件 + 2 个测试文件,生产代码净增约 15 行,测试约 60 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +`fe-connector-trino` 把 Doris 的 OR 谓词翻译成 Trino 的 `TupleDomain` 时只读取前两个分支,第三个及以后的分支被静默丢弃,导致 `WHERE a=1 OR a=2 OR a=3` 在数据源侧被收窄成 `WHERE a=1 OR a=2`,查询结果**少行**。 + +## 二、背景:现在的代码是怎么写的 + +### 谓词在公共接口里是 N 元的 + +`fe-connector-api` 的 `ConnectorOr` 明确文档化为「两个或更多分支」,`getDisjuncts()` 返回一个列表: + +`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorOr.java:25-37` + +```java +/** + * Logical OR of two or more disjuncts. + */ +public final class ConnectorOr implements ConnectorExpression { + private final List disjuncts; + + public ConnectorOr(List disjuncts) { + Objects.requireNonNull(disjuncts, "disjuncts"); + this.disjuncts = Collections.unmodifiableList(disjuncts); + } +``` + +fe-core 侧三个生产者都会先把嵌套的 OR **拉平**成一个多元列表再构造 `ConnectorOr`,所以三路 OR 到达连接器时确实是一个持有 3 个元素的 `ConnectorOr`,不是嵌套的两层二元树: + +- `fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ExprToConnectorExpressionConverter.java:218-221`(`flattenOr` 后 `new ConnectorOr(disjuncts)`) +- `fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/NereidsToConnectorExpressionConverter.java:137-142` +- `fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/UnboundExpressionToConnectorPredicateConverter.java:176` + +### trino 连接器只读了前两支 + +`fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoPredicateConverter.java:114-118` + +```java +private TupleDomain convertOr(ConnectorOr or) { + TupleDomain left = doConvert(or.getDisjuncts().get(0)); + TupleDomain right = doConvert(or.getDisjuncts().get(1)); + return TupleDomain.columnWiseUnion(left, right); +} +``` + +同一个类里紧邻的 `convertAnd`(`:102-112`)是**遍历全部** `getConjuncts()` 的,只有 OR 这一支是取下标。 + +### 转换结果会真的落到数据源上 + +转换出的 `TupleDomain` 有两个消费点,两处都会把它交给 Trino 自己的连接器做实际过滤: + +| 位置 | 用途 | +| --- | --- | +| `TrinoConnectorDorisMetadata.java:255-297`(`applyFilter`) | 交给 Trino 的 `ConnectorMetadata.applyFilter`,换回一个**已下推谓词的表句柄**,这个句柄随后被序列化进扫描分片发给 BE 的 JNI scanner | +| `TrinoScanPlanProvider.java:262-272`(`buildConstraint`) | 包成 `Constraint` 传给 Trino 的 `applyFilter` 与 `ConnectorSplitManager.getSplits`,直接参与分片裁剪 | + +### 兜底重算救不回来 + +`TrinoConnectorDorisMetadata.java:292-296` 有一段注释说明它会把原始表达式作为「剩余谓词」回传,让 BE 再算一遍: + +```java +// Trino tracks the remaining filter as a TupleDomain, not as a Doris ConnectorExpression. +// Returning the original expression keeps BE-side re-evaluation, matching the legacy +// fe-core scan-node behavior. +``` + +但 BE 的重算只能**再筛掉**行,不可能把源端根本没返回的行补回来。所以这层兜底对本缺陷完全无效。 + +## 三、为什么这是个问题 + +1. **这是正确性缺陷,而且是静默的。** 用户看不到任何报错或告警,只是结果行数变少。这类问题往往要等到与其它系统对数才被发现。 +2. **实现与公共接口的契约不符。** 接口说自己是 N 元,实现只处理 2 元。八个连接器里只有 trino 这一家是取下标的——`getDisjuncts()` 的全部其它消费者(paimon `PaimonPredicateConverter.java:134`、es `EsQueryDslBuilder.java:267`、maxcompute `MaxComputePredicateConverter.java:152`、iceberg `IcebergPredicateConverter.java:215` 与 `:633`)都在遍历整个列表。 +3. **公共类没有把这个约束变成可执行的校验。** `ConnectorOr` 的文档写了「两个或更多」,构造器却只做非空判断;而且它只用 `Collections.unmodifiableList` 包了调用方传进来的列表**视图**(调用方之后改自己的列表,节点内容就会跟着变),而同一个包里的 `ConnectorIn`(`ConnectorIn.java:40-41`)是先 `new ArrayList<>(...)` 真拷贝再包不可变的。同包两种写法不一致。 + +### 归责:是本次迁移引入的,不是上游既有行为 + +必须写清,因为处理方式不同: + +- **旧实现是对的。** 迁移前 fe-core 的 `TrinoConnectorPredicateConverter`(`git show master:fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/source/TrinoConnectorPredicateConverter.java`,OR 分支在 `:112-115`)也是读 `getChild(0)` / `getChild(1)` 两个孩子,但它的输入是老 Expr 抽象树里的 `CompoundPredicate`——那个类的构造器只接受两个操作数(`git show master:fe/fe-catalog/src/main/java/org/apache/doris/analysis/CompoundPredicate.java:42-50`),三路 OR 在那里是嵌套的二元树,靠递归天然覆盖全部分支。**读两个孩子在旧模型下是完备的。** +- **迁移换了输入模型但没换读法。** 新的 `ConnectorOr` 是拉平后的 N 元列表,照抄「读两个」就漏了。所以这是**移植时引入的回退**,不是忠实搬运上游缺陷。 +- **注意**:`master` 上已经有这个带缺陷的文件(`fe/fe-connector/fe-connector-trino/...` 在 `master` 上存在,由本次迁移的早期提交 apache/doris#62183 带入)。但 `master` 上 trino 目录仍走 fe-core 老路径(`fe/fe-core/.../trinoconnector/` 在 `master` 上还在,在本分支已删除),所以**只有本分支上这段代码是线上生效路径**。修复应视为修自己引入的回退,不要在提交信息里描述成上游缺陷。 + +## 四、用一个最小例子说明 + +假设一张 trino 目录下的表 `t`,`c_int` 列有 1、2、3 三种值各一行: + +```sql +SELECT * FROM trino_catalog.db.t WHERE c_int = 1 OR c_int = 2 OR c_int = 3; +``` + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +| --- | --- | --- | +| `c_int = 1 OR c_int = 2 OR c_int = 3` | 下推给数据源的域是 `c_int IN (1, 2)`,源端只返回 2 行;BE 再用原谓词过一遍,仍是 2 行 | 下推的域是 `c_int IN (1, 2, 3)`,返回 3 行 | +| `c_int = 1 OR c_int = 2` | 正确(恰好只有两支) | 同左 | +| `c_int = 1 OR c_int = 2 OR c_int = 3 OR c_int = 4` | 只剩 `IN (1, 2)`,丢 2 行 | `IN (1, 2, 3, 4)` | + +结果就是「查得越复杂,丢得越多」,且没有任何提示。 + +## 五、解决方案 + +### 5.1 目标状态 + +**`TrinoPredicateConverter.convertOr` 折叠全部分支。** 逐个转换所有 disjunct,收集成列表后交给 Trino 的 `TupleDomain.columnWiseUnion(List)` 一次性做列级并集: + +```java +private TupleDomain convertOr(ConnectorOr or) { + List> parts = new ArrayList<>(); + for (ConnectorExpression child : or.getDisjuncts()) { + // Not caught per-disjunct on purpose: dropping an OR arm narrows the pushed + // predicate and loses rows. Let the failure propagate so the caller degrades + // to TupleDomain.all() (no pushdown) instead. + parts.add(doConvert(child)); + } + if (parts.isEmpty()) { + return TupleDomain.all(); + } + return TupleDomain.columnWiseUnion(parts); +} +``` + +三个要点: + +1. **不要在循环里 catch 单个分支的异常。** `convertAnd`(`:102-112`)catch 并跳过失败的孩子是安全的——少一个 AND 条件只会让下推**变宽**,BE 会补算回来。OR 相反:少一支就是收窄,正是本缺陷的成因。让异常向上抛,由 `convert`(`:74-84`)的 catch 兜到 `TupleDomain.all()`,即「放弃下推、全量扫描」,语义安全。这与 iceberg 的处理方式一致(`IcebergPredicateConverter.java:212` 有一行注释明写 OR 是 all-or-nothing)。 +2. **`columnWiseUnion(List)` 用不了空列表。** 实测 trino-spi 435 的 `columnWiseUnion(List)` 在列表为空时抛 `IllegalArgumentException("tupleDomains must have at least one element")`,所以必须留空集合守卫返回 `TupleDomain.all()`。加上 5.1 的构造器校验后这条守卫在实践上不可达,但它是廉价的失败安全兜底,保留。 +3. **逐对折叠与一次性并集等价,但仍用 List 重载。** 列级并集里「一个列只有在所有分支都出现时才保留,其域取并集」,逐对折叠与整体计算结果相同;用 `List` 重载只是更直白,也少一次中间对象。 + +**`ConnectorOr` 构造器补齐契约。** 签名不变,只补校验与真拷贝,与同包 `ConnectorIn` 对齐: + +```java +public ConnectorOr(List disjuncts) { + Objects.requireNonNull(disjuncts, "disjuncts"); + if (disjuncts.size() < 2) { + throw new IllegalArgumentException( + "ConnectorOr requires at least two disjuncts, got " + disjuncts.size()); + } + this.disjuncts = Collections.unmodifiableList(new ArrayList<>(disjuncts)); +} +``` + +已核实**全部现存生产者都已满足这个前置条件**,加校验不会打破任何调用点: + +| 生产者 | 是否可能少于 2 个 | +| --- | --- | +| `ExprToConnectorExpressionConverter.java:218-221` | 不会。入参已判定是 OR 型 `CompoundPredicate`,`flattenOr`(`:240-248`)对两个孩子各产出至少一个节点,`convert` 走不通时会 `fallback`(`:342`)返回 SQL 片段节点而非 null | +| `NereidsToConnectorExpressionConverter.java:142` | 不会,已有 `disjuncts.size() == 1 ? disjuncts.get(0) : ...` 的三元判断 | +| `UnboundExpressionToConnectorPredicateConverter.java:176` | 不会,同上 | +| 各连接器与 fe-core 的测试用例 | 已核实全部传 2 个以上 | + +### 5.2 改动清单 + +| 文件 | 做什么 | +| --- | --- | +| `fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoPredicateConverter.java` | 重写 `convertOr`(`:114-118`)为遍历全部 disjunct + `columnWiseUnion(List)` + 空集合守卫;不要加 per-disjunct 的 try/catch,并留一行注释说明「OR 少一支等于收窄」这个理由 | +| `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorOr.java` | 构造器(`:34-37`)加「至少两个分支」校验 + `new ArrayList<>(disjuncts)` 防御性拷贝;`import java.util.ArrayList` | +| `fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoPredicateConverterTest.java` | 在现有 `testOrUnionsSameColumn`(`:215-224`)旁新增三路与四路 OR 用例,并补一个跨列 OR 用例与一个含不可转换分支的 OR 用例(见第六节) | +| `fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/pushdown/ConnectorOrTest.java`(新建) | 校验构造器拒绝 0/1 个分支、接受 2 个以上、以及「构造后修改调用方原列表不影响节点内容」 | + +### 5.3 明确不要顺手做的事 + +- **不要给 `ConnectorAnd` 一起加校验和拷贝。** 它是同样的浅包裹形状(`ConnectorAnd.java:34-37`),但已核实它的所有生产者(`WriteConstraintExtractor.java:86`、两个 Nereids 转换器、`ExprToConnectorExpressionConverter`)都已经守住了「只剩一个就不包 AND」,今天没有实际风险。把它一起改会让这次提交从「修一个会丢行的缺陷」变成「顺带整理公共类」,稀释了变更意图,也需要另做一遍全生产者核对。留给后续的一致性清理。 +- **不要顺手实现「剩余谓词」的精细化。** `TrinoConnectorDorisMetadata.java:292-296` 的注释里提到「未来可以把剩余 TupleDomain 映射回 ConnectorExpression 并清掉已完全下推的条件」。那是性能优化(少一次 BE 重算),与本缺陷无关,而且清错了就是另一次丢行。 +- **不要动 `convertAnd` 的「catch 并跳过」写法。** 它对 AND 是安全的(下推变宽),改成 all-or-nothing 只会降低下推率。 +- **不要在 fe-core 侧做任何改动。** fe-core 现阶段只出不进,这个缺陷完全在连接器与公共接口类里,无需碰引擎。 +- **不要写脚本或正则门禁去检查「是否遍历了 getDisjuncts()」。** 那是语言语义判断,本仓库已有结论:这类静态门禁误报比漏报更毒。用单元测试锁住行为即可。 + +## 六、怎么验证 + +### 单元测试要断言什么 + +在 `TrinoPredicateConverterTest` 里新增(现有 `CONVERTER` / `col()` / `expect()` / `singleValue()` 辅助方法直接可用,见 `:89-107`): + +1. **三路同列 OR** —— `c_int=1 OR c_int=2 OR c_int=3` 必须得到 `c_int` 上含三个等值 range 的域。**这条在修复前必须失败**(改代码前先跑一次确认它红,这是本任务唯一的变异验证要求:如果它在旧代码上就绿,说明用例没打到缺陷)。 +2. **四路同列 OR** —— 证明修的是「全部分支」而不是把 2 改成 3。 +3. **三路跨列 OR** —— 例如 `c_int=1 OR c_bigint=2 OR c_str='x'`,列级并集下没有任何列在所有分支中都出现,结果必须是 `TupleDomain.all()`(放弃下推、不收窄)。这条锁住「不要为了下推而编造约束」。 +4. **含不可转换分支的 OR** —— 例如 `c_int=1 OR c_int=2 OR <一个裸列引用>`,结果必须是 `TupleDomain.all()`,**不能**是 `c_int IN (1,2)`。这条正面锁住 5.1 里「不要 per-disjunct catch」的决定,是防止本缺陷以另一种形态复活的关键用例。 +5. **保留现有的两路 OR 用例不改**,作为不回退的基线。 + +在新建的 `ConnectorOrTest` 里断言:0 个和 1 个分支抛 `IllegalArgumentException`;2 个及以上正常;构造后往调用方原 `ArrayList` 里再 `add` 一个分支,`getDisjuncts()` 的大小不变(证明是真拷贝)。 + +### 命令 + +单模块测试(必须禁用 maven build cache,否则 surefire 会被静默跳过而报 BUILD SUCCESS): + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -pl fe-connector/fe-connector-trino,fe-connector/fe-connector-api -am \ + -Dmaven.build.cache.enabled=false \ + -Dtest=TrinoPredicateConverterTest,ConnectorOrTest -DfailIfNoTests=false test +``` + +编译门禁(最强的单一信号,验收必跑;`ConnectorOr` 加了校验,要靠它确认没有测试源在别处构造单分支 OR): + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -Dmaven.build.cache.enabled=false test-compile +``` + +不要加任何跳过测试编译的参数。checkstyle 会扫测试源,新增文件要过。 + +### 端到端回归 + +已有 `regression-test/suites/external_table_p0/trino_connector/jdbc/` 下的用例(如 `test_trino_pg.groovy`)跑的是真实 trino 目录,可以在其中一个里补一条三路 OR 的查询与结果断言。**这一步需要 docker 外部环境,本 session 通常跑不了**——补了用例后如实标注「未在本地执行」,不要声称已通过。单元测试已经能覆盖转换逻辑本身,端到端只是额外保险。 + +## 七、风险与回退 + +- **风险很低。** 生产改动是一个私有方法的循环化,加上一个公共构造器的前置校验。改动方向是「让下推更完整」,不会产生新的收窄。 +- **唯一需要留意的是新加的构造器校验会硬抛异常。** 已逐一核对全部生产者与测试用例都传 2 个以上(见 5.1 的表),并且 `mvn test-compile` + 全量单测能把遗漏暴露出来。若后续有人在别处构造单分支 OR,会立刻在构造点抛错而不是静默降级——这是刻意选择:单分支 OR 是调用方的逻辑错误,应当被发现。 +- **下推变完整后,某些查询扫到的数据会比修复前多。** 这不是回退,是本来就该读的数据。但如果有依赖旧(错误)行数的回归用例基线,需要更新基线而不是回滚修复。 +- **回退方式**:两个文件各自独立可回滚。若只想回退构造器校验、保留转换器修复,也是可行的——转换器的修复不依赖校验。 + +## 八、相关背景 + +- 调研报告 `plan-doc/connector-public-interface-cleanup/audit-report.md`: + - 第十一节 11.1 第(1)条 —— 三路以上 OR 在源端丢行,本任务对应条目;同一小节末尾的归责段 —— 四条缺陷分别归谁(那里把本条归为「trino 连接器的实现问题」,本文进一步核实为「迁移引入的回退」)。 + - 附录 A.6 第 114 条 —— 原始判定与符号定位。 + - 第十五节整治路线表第 6 项 —— 修四个真实缺陷的排期;附录 C.4 第 1 条 —— 全部材料里唯一可能静默少数据的问题。 +- 同一批缺陷里的相邻项(各自独立任务,不要合并进本次提交):复杂类型字段名被丢弃(审计 11.1 第(2)条 / 附录 A.6 第 116 条 —— 引擎编造替代字段名)、paimon 的空值安全比较被译成 `IS NULL`(审计 11.1 第(3)条 —— `<=>` 被译成 IS NULL)。 +- 正确处理 N 元 OR 的可参考实现:`fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergPredicateConverter.java:212-223`(含「OR 是 all-or-nothing」的注释)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/02-fix-paimon-null-safe-equal-pushdown.md b/plan-doc/connector-public-interface-cleanup/tasks/02-fix-paimon-null-safe-equal-pushdown.md new file mode 100644 index 00000000000000..c115a3f5e1896b --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/02-fix-paimon-null-safe-equal-pushdown.md @@ -0,0 +1,185 @@ +# 02. 修复 paimon 连接器把「空值安全等于」下推成 IS NULL 导致的丢行 + +> **优先级**:第一优先级(正确性缺陷) | **风险**:低 | **前置依赖**:无(与「下推表达式契约补全」任务同源,但两者可各自独立完成,本任务不必等契约文字先落地) +> **影响模块**:`fe-connector-paimon`(主代码 1 个文件 + 测试 1 个文件),不触碰 `fe-core`,不触碰 `fe-connector-api` +> **预计改动规模**:2 个文件;主代码约 10 行,测试约 60 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +paimon 连接器把 `列 <=> 非空字面量`(空值安全等于)翻译成了「该列 IS NULL」,方向完全相反;要改成「右边是非空字面量就翻成等值比较,右边是空值字面量才翻成 IS NULL」,与 iceberg / trino / es 三家已经写对的做法一致。 + +## 二、背景:现在的代码是怎么写的 + +谓词下推的入口是 `fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java`。它把引擎交下来的中立表达式树(`ConnectorExpression`)翻译成 paimon 自己的 `Predicate`,翻不出来就返回 `null`,让 BE 端自己再过滤一遍——这是安全的放宽方向。 + +出问题的是比较表达式的翻译函数 `convertComparison`(`PaimonPredicateConverter.java:144-178`): + +```java +Object value = convertLiteralValue(literal, fieldTypes.get(idx)); // :156 +if (value == null) { // :157 + return null; // :158 +} +switch (cmp.getOperator()) { + case EQ: + return builder.equal(idx, value); // :162 + ... + case EQ_FOR_NULL: + return builder.isNull(idx); // :173-174 + default: + return null; +} +``` + +关键在于 `convertLiteralValue`(`PaimonPredicateConverter.java:244-247`)开头就写着: + +```java +if (literal.isNull()) { + return null; +} +``` + +也就是说:**空值字面量在 :156 就已经被变成 `null`,在 :157 被提前 return 掉了**。等执行流走到 :173 的 `case EQ_FOR_NULL` 时,右操作数一定是个非空字面量——却返回了 `builder.isNull(idx)`。 + +注意 `value == null` 在这里有两种完全不同的含义,代码把它们混在一起了:一是「字面量本身是空值」,二是「这个 paimon 类型故意不下推」(同文件里 FLOAT、CHAR、TIMESTAMP WITH LOCAL TIME ZONE 都会返回 `null`,见测试 `floatNotPushed` / `charNotPushed` / `ltzNotPushed`)。修复时必须靠 `literal.isNull()` 把两者区分开。 + +翻出来的谓词有两个下游消费点,都在 `PaimonScanPlanProvider.java`: + +- `:506` 把它交给 paimon 的 `ReadBuilder.withFilter`,在 FE 规划阶段做分区级 / 数据文件级裁剪; +- `:783` 把它序列化进 `paimon.predicate` 属性发给 BE 的 paimon JNI reader,在读取时再过滤一次。 + +顺带说明:paimon 连接器不声明「已消费某个 conjunct」,所以原始条件仍会作为残余过滤在 BE 上重算(`PluginDrivenScanNode.buildRemainingFilter`)。残余过滤只能再减行,不可能把 FE 阶段已经裁掉的文件找回来。 + +**同一个算子,其他四家都是对的:** + +| 连接器 | 位置 | 做法 | +| --- | --- | --- | +| iceberg | `IcebergPredicateConverter.java:245-251` 与 `:252-254` | 只有「值转换失败且 `literal.isNull()` 且算子是空值安全等于」才转 `Expressions.isNull`;非空时和普通等值走同一分支 | +| trino | `TrinoPredicateConverter.java:132-139` | 显式按 `((ConnectorLiteral) cmp.getRight()).isNull()` 分两支:`Domain.onlyNull` 还是 `Range.equal` | +| es | `EsQueryDslBuilder.java:396-405` | `value == null` 走 `must_not exists`,否则走 term 查询;并有两个专测 `EsQueryDslBuilderTest.java:176` 与 `:201` | +| maxcompute | `MaxComputePredicateConverter.java:172-173` | 注释说明 ODPS 无对应算子,落到 default 直接放弃下推 | + +**归责(必须写清)**:这不是本次连接器迁移引入的回退。`git show master:fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonPredicateConverter.java` 里的老实现,在 `binaryExprDesc` 中同样是先 `if (value == null) return null;`,再 `case EQ_FOR_NULL: return builder.isNull(idx);`——写法逐字相同。这是上游既有缺陷的忠实移植,本任务是顺手把它修掉。 + +## 三、为什么这是个问题 + +1. **翻译方向相反,不是收窄而是错。** 下推的容许方向只有「放宽」(多读点、让 BE 再过滤)。把「等于 5」翻成「为空」既不是原条件的超集也不是子集,两者的结果集是互斥的。 +2. **后果是静默少行。** FE 阶段按「IS NULL」裁剪,含 `c=5` 的数据文件根本不会进入扫描列表;BE 的残余过滤只能删行,补不回来。用户观察到的现象是:`WHERE c <=> 5` 返回 0 行(或只剩恰好也满足 IS NULL 的行),**没有任何报错、没有 warning**,`EXPLAIN` 里看到的裁剪后分区数也偏小。 +3. **默认会话下这条缺陷目前是潜伏的,但仍必须修。** 这一点是对早期调研结论的修正:Nereids 有一条改写规则 `NullSafeEqualToEqual`(`fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/NullSafeEqualToEqual.java:66-87`),在过滤条件位置只要有一边不可为空(字面量恒不可为空),就会把 `c <=> 5` 提前改写成 `c = 5`,所以走普通 `WHERE` 时坏分支通常到不了连接器。但是: + - 这条规则带 `ExpressionRuleType.NULL_SAFE_EQUAL_TO_EQUAL` 标签,可以被会话变量 `disable_nereids_rules` 关掉(`ExpressionPatternRules.java:78/95/111` 按该标签查 `disableRules` 位图)。关掉之后就是一条实打实的错误结果查询。 + - 连接器的翻译正确性不能建立在「某条可关闭的优化规则一定先跑过」之上。等值下推是连接器自己的契约义务。 + - 一旦坏分支出现在 OR 里(`convertOr`,`PaimonPredicateConverter.java:132-142`),错误的那一支会污染整个 OR 谓词,影响范围比单个 conjunct 更大。 +4. **根因在公共接口一侧。** 比较算子的公共契约总共只有一行文字:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorComparison.java:27` 写着 `Supported operators: EQ, NE, LT, LE, GT, GE, EQ_FOR_NULL.`,然后 `:41` 给了个 `EQ_FOR_NULL("<=>")` 的符号。没有任何地方写明「右操作数可能是空值字面量」、「空值安全等于的语义是什么」、「不能精确翻译时必须放弃下推、只准放宽不准收窄」。于是五个消费者各写一遍,四家写对一家写错。**补这段契约文字是另一个任务(下推表达式契约补全)的事,本任务不改 `ConnectorComparison`。** + +## 四、用一个最小例子说明 + +建一张 paimon 表,`c` 列可为空,写入三行:`c = 5`、`c = 7`、`c = NULL`。 + +```sql +-- 让优化器不要抢先把 <=> 改写掉,直接压到连接器的翻译逻辑上 +set disable_nereids_rules = 'NULL_SAFE_EQUAL_TO_EQUAL'; + +SELECT * FROM paimon_ctl.db.t WHERE c <=> 5; +``` + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +| --- | --- | --- | +| `WHERE c <=> 5` | 下推给 paimon 的是 `isNull(c)`;只有 `c IS NULL` 的文件被读进来,BE 再用原条件 `c <=> 5` 过滤 → **返回 0 行** | 下推 `equal(c, 5)` → 返回 `c = 5` 那一行 | +| `WHERE c <=> NULL` | 空值字面量在 `convertLiteralValue` 就被丢掉 → 整个条件不下推,BE 全量过滤 → 结果正确,只是白读数据 | 下推 `isNull(c)` → 结果同样正确,且能做文件级裁剪 | +| `WHERE c = 5` | 下推 `equal(c, 5)` → 正确(对照组,说明问题只在空值安全等于这一支) | 不变 | + +一句话:第一行现在是**错的**(少行),第二行现在是**对但浪费**的,本任务把两行都摆正。 + +## 五、解决方案 + +### 5.1 目标状态 + +`convertComparison` 里把「值转换失败」这个统一出口拆成两种情形,形状与 iceberg 对齐(先判空值安全等于的空值特例,再走原有 switch): + +```java +Object value = convertLiteralValue(literal, fieldTypes.get(idx)); +if (value == null) { + // 只有 `col <=> NULL` 能在没有值的情况下翻译:它等价于 IS NULL。 + // 其他情况(普通比较遇空值字面量、或该 paimon 类型故意不下推)一律放弃下推。 + if (cmp.getOperator() == ConnectorComparison.Operator.EQ_FOR_NULL && literal.isNull()) { + return builder.isNull(idx); + } + return null; +} +switch (cmp.getOperator()) { + case EQ: + case EQ_FOR_NULL: // 右边是非空字面量时,`<=>` 与 `=` 的结果集完全一致 + return builder.equal(idx, value); + ... + // 原 `case EQ_FOR_NULL: return builder.isNull(idx);` 删除 +} +``` + +为什么非空时用 `equal` 是精确等价而不是放宽:`c <=> 5` 为真当且仅当 `c = 5`(`c` 为空时结果是 false 而非 unknown)。paimon 的 `Equal` 继承 `NullFalseLeafBinaryFunction`,空值本身不会匹配 `equal`,语义正好吻合。 + +**注意判定顺序**:必须先判 `literal.isNull()` 再判类型,不能只判「算子是 `EQ_FOR_NULL`」就返回 `isNull`——否则一个 FLOAT 列上的 `c <=> 1.5`(值转换因类型故意不下推而失败)又会被翻成 IS NULL,等于换个地方复发同一个 bug。 + +### 5.2 改动清单 + +| 文件 | 改什么 | +| --- | --- | +| `fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java` | `convertComparison`:`:157-159` 的 `value == null` 出口内加空值安全等于 + `literal.isNull()` 的 IS NULL 分支;`:173-174` 的 `case EQ_FOR_NULL` 从返回 `isNull` 改为与 `case EQ` 合并(fall-through 到 `builder.equal`)。两处都补注释说明为什么。 | +| `fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java` 类注释 | 类头注释里补一句「空值安全等于按右操作数是否为空值字面量分流」,与 iceberg 同类注释口径一致。可选但推荐。 | +| `fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java` | 新增测试,见第六节。现有的 `convertEq` 辅助方法只造 `EQ`,需要再加一个能指定算子和空值字面量的辅助方法(`ConnectorLiteral.ofNull(type)` 已存在,见 `ConnectorLiteral.java:45`)。 | + +### 5.3 明确不要顺手做的事 + +- **不要改 `ConnectorComparison.java`**(不改那行 `Supported operators`、不改枚举、不加 javadoc 契约段落)。契约补全是另一个任务的范围,两个任务同时改同一行会互相打架。 +- **不要顺手修同一个文件里 LIKE 的收窄缺陷**(`convertLike`,`PaimonPredicateConverter.java:218-239`,含单字符通配符 `_` 的模式被当成前缀匹配)。那是独立的一条,单独一个任务、单独一次提交,便于回退定位。 +- **不要动 iceberg / trino / es / maxcompute 的转换器**。四家已核实为正确实现,改它们只会引入风险。 +- **不要在 `fe-core` 里加一个「统一改写空值安全等于」的公共 helper**。当前阶段 `fe-core` 数据源相关代码只出不进;而且在引擎侧统一改写会把连接器各自的翻译义务藏起来,下一个连接器照样会写错。 +- **不要去改 Nereids 的 `NullSafeEqualToEqual` 规则**,也不要为了「反正优化器会改写」就不修连接器。优化器改写是加分项,不是正确性依据。 +- **不要新增 `supportsXxx()` 能力位**。这不是「paimon 不支持某个能力」,而是「翻译写错了」,能力位在这里没有意义。 +- **不要写 shell / 正则的构建门禁**去校验「`case EQ_FOR_NULL` 后面不许接 `isNull`」。本仓库已有明确结论:静态门禁只适合存在性与前缀类不变量,要理解 switch 分支极性就等于在 shell 里写 Java 解析器,误报比漏报更毒。用单元测试 + 注释即可。 + +## 六、怎么验证 + +**单元测试(必须)**,加在 `PaimonPredicateConverterTest.java`,全部离线(转换器只需要一个 `RowType`,不需要 catalog): + +1. `eqForNullWithNonNullLiteralPushesEqual`:INT 列上构造 `ConnectorComparison(EQ_FOR_NULL, col("id"), literal 5)`,断言产出 1 个谓词,且 `((LeafPredicate) p).function()` 是 `org.apache.paimon.predicate.Equal`(可用 `Assertions.assertSame(Equal.INSTANCE, leaf.function())`),`leaf.literals().get(0)` 等于 `5`。 + 注释要写清 WHY:**如果这里翻成 IS NULL,含 `id=5` 的数据文件会在 FE 规划阶段被裁掉,查询静默少行**。 +2. `eqForNullWithNullLiteralPushesIsNull`:同一列上用 `ConnectorLiteral.ofNull(...)`,断言 `function()` 是 `org.apache.paimon.predicate.IsNull`,且 `literals()` 为空。 +3. `plainEqWithNullLiteralNotPushed`:`ConnectorComparison(EQ, col, ofNull(...))` 必须一个谓词都不产生(普通等值遇空值字面量结果恒为 unknown,不能翻成 IS NULL 也不该翻成 equal)。 +4. `eqForNullOnNonPushableTypeNotPushed`:FLOAT 列上 `EQ_FOR_NULL` 配非空字面量 `1.5`——值转换会失败(FLOAT 故意不下推),断言产出为空,**不能**变成 IS NULL。这条专门守住 5.1 里提到的判定顺序。 + +**变异验证(必须做,写进测试注释)**:把改好的 `case EQ_FOR_NULL` 改回 `return builder.isNull(idx)` → 第 1 条测试必须变红;把第 4 条对应的守卫条件从 `literal.isNull()` 放宽成只判算子 → 第 4 条必须变红。两条测试各自能被对应的错误写法打红,才算测到了意图而不是行为快照。 + +**编译门禁(最强单一信号)**:全反应堆含测试源编译,**不许**加跳过测试编译的参数。 + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile -DskipTests +``` + +**跑测试**必须显式关掉 maven build cache,否则 surefire 会被静默跳过、`BUILD SUCCESS` 是空的: + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -pl fe-connector/fe-connector-paimon \ + -Dmaven.build.cache.enabled=false \ + -Dtest=PaimonPredicateConverterTest test +``` + +要读输出里的 `Tests run:` 行确认用例真的跑了,不要只看 `BUILD SUCCESS`。 + +**端到端回归(可选,需要 docker 环境,本地不跑)**:现有 paimon 回归套件里检索不到任何 `<=>` 用例。如果要补,注意两点:一是必须在用例里 `set disable_nereids_rules='NULL_SAFE_EQUAL_TO_EQUAL'`,否则优化器会先把条件改写掉,用例根本压不到连接器路径,改坏了也照样绿;二是断言要同时覆盖 `c <=> 非空值`(有行)、`c <=> NULL`(只出空值行)和 `c = 非空值`(对照)。这一项不阻塞本任务合并。 + +## 七、风险与回退 + +风险低。行为变化被严格限制在「算子是空值安全等于」这一条分支上,其余算子的代码路径逐字不变,第 3、4 条测试就是用来钉住这一点的。 + +唯一的新增行为是「`c <=> NULL` 现在会下推成 IS NULL」——这是从「不下推」变成「下推一个语义正确的 IS NULL」,属于新增裁剪能力。paimon 的 `isNull` 叶子谓词只依赖空值计数统计,与列类型无关,因此即使在 FLOAT / CHAR / 带本地时区时间戳这些故意不下推值比较的列上也是安全的(iceberg 就是这么做的)。如果评审希望把改动面压到最小,可以只做「非空字面量 → equal」这一半,把空值字面量继续留给 BE 过滤;两种方案都修掉了正确性缺陷,前者额外多一点裁剪收益。 + +回退:单文件 `git revert`,无跨模块耦合,无持久化格式、无 thrift 有线格式、无公共接口签名变化。 + +## 八、相关背景 + +- 调研报告 `plan-doc/connector-public-interface-cleanup/audit-report.md`: + - 「主题八:实现与接口定义不符」→「11.1 四个有实际用户可见后果的缺陷」的第(3)条 —— `<=>` 被译成 IS NULL,就是本任务的来源; + - 「十五、建议的整治路线」里的分组表第 6 项 —— 修四个真实缺陷的排期; + - 附录 D.7 末尾一条 —— 四家兄弟连接器写法均正确:确认只有 paimon 错,并已用 `git show master` 确认属上游既有缺陷的移植。 +- 相关任务:**下推表达式契约补全**(把「右操作数可能为空值字面量」「只准放宽不准收窄」写进 `ConnectorComparison` 的公共契约),它解决的是根因;本任务解决的是已经发生的后果。 +- 同一文件另一条独立缺陷:paimon 的 LIKE 把含单字符通配符的模式收窄成前缀匹配,单列一个任务处理。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/03-fix-paimon-like-prefix-narrowing.md b/plan-doc/connector-public-interface-cleanup/tasks/03-fix-paimon-like-prefix-narrowing.md new file mode 100644 index 00000000000000..1b890e0d0b72d3 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/03-fix-paimon-like-prefix-narrowing.md @@ -0,0 +1,186 @@ +# 03. 修复 paimon 连接器把含单字符通配符或转义的 LIKE 收窄成前缀匹配 + +> **优先级**:第一优先级(正确性缺陷,会静默少行) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe/fe-connector/fe-connector-paimon`(主改动 + 单元测试);`regression-test`(新增一个端到端用例)。**不动** `fe-connector-api`,**不动** `fe-core`。 +> **预计改动规模**:生产代码 1 个文件、约 20 行;单元测试 1 个文件、约 80 行;端到端用例 1 个 groovy 文件、约 70 行。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +paimon 连接器在把 Doris 的 `LIKE` 谓词下推给 Paimon 时,只看「模式串是不是以 `%` 结尾」就当成前缀匹配,完全没有检查模式串里有没有单字符通配符 `_`、有没有反斜杠转义、有没有夹在中间的 `%`;这三种情况下下推出去的谓词都比原谓词更严格,Paimon 会据此跳过本该被读到的数据文件,查询**静默少行**。本任务把这个下推收紧成「只有能证明等价时才下推,否则放弃下推」。 + +## 二、背景:现在的代码是怎么写的 + +Doris 把查询过滤条件翻成连接器可消费的表达式树(`ConnectorExpression`),`LIKE` 会变成 `ConnectorLike`。paimon 连接器用 `PaimonPredicateConverter` 把这棵树翻成 Paimon SDK 的 `Predicate`。 + +出问题的就是这个转换方法,`fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java:233-238`: + +```java +String pattern = ((ConnectorLiteral) patternExpr).getValue().toString(); +if (!pattern.startsWith("%") && pattern.endsWith("%")) { + String prefix = pattern.substring(0, pattern.length() - 1); + return builder.startsWith(idx, BinaryString.fromString(prefix)); +} +return null; +``` + +即:**只要模式串不以 `%` 开头、且以 `%` 结尾,就把「去掉最后一个字符」的结果当作字面前缀**交给 Paimon 的 `startsWith`。除此之外的模式串(同一方法的 `:238`)返回 `null`,表示放弃下推——这部分是对的。 + +转换出来的谓词有两个消费点,都在 `PaimonScanPlanProvider.java`: + +- FE 规划期:`:488` 调转换器,`:506` `readBuilder.withFilter(predicates)`。Paimon SDK 在 `newScan().plan()` 里用这些谓词做分区裁剪与数据文件裁剪(该类的类注释 `:120-125` 明确说明 paimon 是「纯谓词驱动」的裁剪,连引擎给的分区集都不消费)。 +- BE 读取期:`:780-783` 再转换一遍,序列化进 `paimon.predicate` 交给 BE 的 Paimon JNI scanner,做行级过滤。 + +两条路都意味着:**下推的谓词一旦比原谓词严格,被裁掉的文件/被过滤掉的行 BE 侧再也补不回来**(BE 上仍挂着原始 `LIKE` 过滤,但它只能过滤已经读到的行,不能把跳过的文件读回来)。 + +两条相关事实也已核对: + +- `ConnectorLike` 的公共契约总共只有一句话——「A LIKE/REGEXP predicate: `value LIKE pattern`」(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/ConnectorLike.java:24-26`),枚举只有 `LIKE` / `REGEXP` 两个值(`:32-35`)。**没有任何地方约定转义字符是什么、`%` 与 `_` 的方言、正则是部分匹配还是整串锚定、大小写敏感性,也没有约定「翻不精确就必须放弃下推」。** 契约缺失是这类实现错误能活下来的土壤。 +- Doris 的 `LIKE` 默认转义字符是反斜杠:BE 侧实现 `be/src/exprs/function/like.cpp` 的快速路径正则 `:58` 明确把 `\%` 与 `\_` 当成转义后的字面量处理,并在 `:815` 的 `remove_escape_character` 里去掉转义再做前缀/后缀/子串匹配;自定义 `ESCAPE` 走 `:980` 之后的 `has_custom_escape` 分支。也就是说 BE 自己**是**做了 paimon 这里缺的那层守卫。 + +**归责**:这不是本次迁移引入的回退。`git show master` 对比确认,老的 fe-core 实现 `master:fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonPredicateConverter.java:164-165` 是完全相同的写法(`name.equals("like") && !s.startsWith("%") && s.endsWith("%")` → `startsWith(s.substring(0, s.length() - 1))`)。属于上游既有缺陷的忠实移植。 + +## 三、为什么这是个问题 + +违反的原则只有一条,但很硬:**谓词下推只允许放宽,不允许收窄。** 引擎把过滤条件交给数据源,是为了让数据源少读数据;数据源如果把条件理解得比原意更严格,就会跳过本该返回的数据,而引擎无法察觉——因为返回的行数看起来「合理」,只是少了。 + +用户能观察到的现象:**同一张 paimon 表、同一条带 `LIKE` 的 SQL,结果比正确答案少行**,且没有任何报错、日志或 `EXPLAIN` 提示。切到别的引擎(Spark 读同一张 paimon 表)结果不一致。这类问题在生产上极难定位,因为它不崩、不慢、只是答案不对。 + +具体有三种模式串会踩中(第三种是本次核实时新发现的,最初那轮调研里没有): + +1. **含单字符通配符 `_`**:`LIKE 'a_c%'` 里 `_` 应匹配任意一个字符,被当成字面下划线 → 下推 `startsWith("a_c")` → 真正含 `abc…` 的文件被裁掉。 +2. **含反斜杠转义**:`LIKE 'a\%%'` 的原意是「以字面量 `a%` 开头」,前缀应为 `a%`,但代码原样取到 `a\%`(带反斜杠)→ 下推 `startsWith("a\\%")` → 一行都匹配不上,结果可能直接空集。 +3. **`%` 夹在中间**:`LIKE 'a%b%'` 不以 `%` 开头、以 `%` 结尾 → 代码取前缀 `a%b` 并当成字面串 → 下推 `startsWith("a%b")`。这是同一个漏洞的第三种表现,修法相同。 + +一个附带确认(不用改):`NOT LIKE` 不受影响。fe-core 把 `NOT` 翻成 `ConnectorNot`(`ExprToConnectorExpressionConverter.java:223-224`),而 paimon 的 `convertSingle`(`PaimonPredicateConverter.java:105-118`)没有 `ConnectorNot` 分支,直接返回 `null` 放弃下推。带 `ESCAPE` 子句的三参数 `LIKE` 也进不来(`ExprToConnectorExpressionConverter.java:117` 要求恰好两个子表达式)。 + +**诚实标注**:上述「少行」是从代码路径推断出来的,**尚未跑端到端验证**。所以本任务的第一步就是先写出一个能复现的端到端用例,先看到红,再动生产代码。 + +## 四、用一个最小例子说明 + +准备一张 paimon 表,两行数据,**分两次 insert**(这样两行落在两个不同的数据文件里,文件级统计就能触发裁剪): + +```sql +-- 数据:第一个文件只有 'abc1',第二个文件只有 'a_c1' +-- 查询(_ 是通配符,应该同时命中两行) +SELECT s FROM paimon_tbl WHERE s LIKE 'a_c%' ORDER BY s; +``` + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +| --- | --- | --- | +| `s LIKE 'a_c%'` | 下推 `startsWith("a_c")` → 只有 `'a_c1'` 所在文件被读,返回 1 行 | `_` 是通配符,两行都该返回:`a_c1`、`abc1` | +| `s LIKE 'a\%%'`(找以字面 `a%` 开头的值) | 下推 `startsWith("a\%")`(多了个反斜杠)→ 可能返回 0 行 | 返回所有以 `a%` 开头的行 | +| `s LIKE 'a%b%'` | 下推 `startsWith("a%b")`(`%` 被当字面量)→ 少行 | 返回以 `a` 开头、中间有 `b` 的行 | +| `s LIKE 'abc%'`(唯一安全的形态) | 下推 `startsWith("abc")` | 不变,继续下推 | + +修完之后的判断逻辑,用伪代码表达就是这么几行: + +``` +若 pattern 含 '_' 或含 '\' -> 不下推(返回 null) +去掉 pattern 末尾连续的 '%',得到 body +若 body 为空,或 body 里还有 '%' -> 不下推 +否则 -> startsWith(body) +``` + +「含反斜杠就整体放弃」这条看起来粗,但它同时保证了「去掉末尾 `%`」不会误剥一个被转义的 `%`——因为到那一步已经确定串里没有反斜杠了。宁可少下推几个模式串,也不能下推错。 + +## 五、解决方案 + +### 5.1 目标状态 + +`PaimonPredicateConverter.convertLike` 只在**能证明等价**时才产出 `startsWith`,其余一切模式串返回 `null`(走不下推、由 BE 全量过滤,慢但正确)。不新增 SPI、不新增能力位、不改公共接口签名,只在连接器内部收紧一个判断。 + +建议把判断抽成同类里的一个私有静态方法,便于单测直接打: + +```java +/** + * Returns the literal prefix a Doris LIKE pattern is equivalent to, or null when the + * pattern cannot be proven equivalent to a prefix match. Declining is always safe; + * narrowing is not (Paimon prunes files from this predicate and BE cannot recover them). + */ +private static String literalPrefixOrNull(String pattern) +``` + +`convertLike` 里的调用形态: + +```java +String prefix = literalPrefixOrNull(pattern); +if (prefix == null) { + return null; +} +return builder.startsWith(idx, BinaryString.fromString(prefix)); +``` + +### 5.2 改动清单 + +| 文件 | 做什么 | +| --- | --- | +| `fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonPredicateConverter.java` | 新增私有静态 `literalPrefixOrNull(String)`;`convertLike`(`:218-239`)里把 `:234-237` 那段替换为调用它。方法注释写清「Doris LIKE 默认转义符是反斜杠;不可精确翻译必须放弃下推,不得收窄」,并说明为什么收窄在这里是致命的(谓词同时用于 Paimon 文件裁剪与 BE JNI 行过滤,见 `PaimonScanPlanProvider:506` 与 `:783`)。 | +| `fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonPredicateConverterTest.java` | 新增一组 LIKE 用例(该文件目前**一个 LIKE 用例都没有**,已核实 grep 无命中)。见第六节的断言清单。 | +| `regression-test/suites/external_table_p0/paimon/`(新增一个 groovy 文件,例如 `test_paimon_like_pushdown.groovy`) | 端到端复现 + 回归。用 `spark_paimon_multi`(框架方法在 `regression-test/framework/.../Suite.groovy:1692`)建表并**分多次 insert** 制造多个数据文件,然后断言 `LIKE` 查询的结果集。可参考同目录 `test_paimon_partition_schema_filter_refs.groovy:130-160` 的建表/写入写法,catalog 属性抄 `test_paimon_predict.groovy:30-38`。 | + +### 5.3 明确不要顺手做的事 + +- **不要去补 `ConnectorLike` 的公共契约文档/校验。** 「把逐算子语义与『不可精确翻译必须放弃下推』写进公共契约」是另一份任务的范围(见 audit-report.md 11.1 节末尾那段结论)。两边同时动 `fe-connector-api` 会互相冲突,本任务只修连接器实现。 +- **不要顺手修 es 那处同根因的问题。** 详见第八节:`EsQueryDslBuilder.java:512-522` 把 Doris 的 `REGEXP` 模式原样交给 ES 的 `regexp` 查询,锚定语义不同。是同一个根因的第二处,但涉及 ES 侧语义与另一套端到端环境,**是否同批修由排期决定**,不要塞进本任务。 +- **不要顺手扩大下推能力**(比如给 `%abc` 加后缀匹配、给 `%abc%` 加子串匹配)。那是性能增强,不是本任务的正确性修复,且要先核实所用 Paimon SDK 版本是否真有对应的 `endsWith` / `contains` 构造器。混在一起会让「这次到底修了什么」说不清。 +- **不要试图在连接器里建模排序规则与大小写敏感性。** 现状是 Doris `LIKE` 与 Paimon `startsWith` 都按字节比较,本任务不引入这个维度。 +- **不要顺手处理不含任何通配符的 `LIKE 'abc'`。** 现状是不下推(等价于 `=`,是个可下推机会),但那是增强不是修 bug。 +- **不要往 fe-core 加任何东西。** 当前阶段 fe-core 只出不进,这个修复完全在插件内部可解。 + +## 六、怎么验证 + +**第一步(先看到红)**:先写端到端用例并跑通「现在是错的」。用例形态:建一张带字符串列的 paimon 表,分三次 insert 分别写入 `abc1`、`a_c1`、`a%b1`,然后断言 + +- `WHERE s LIKE 'a_c%'` 返回 `a_c1` 与 `abc1` 两行(修复前预计只返回 1 行); +- `WHERE s LIKE 'a\\%b%'` 返回 `a%b1`(修复前预计 0 行); +- `WHERE s LIKE 'a%b%'` 返回 `a%b1` 与 `abc1`(`abc1` 里 `a` 后有 `b`); +- `WHERE s LIKE 'abc%'` 仍返回 `abc1`(证明安全形态的下推没被误伤)。 + +如果某条在修复前就是绿的,**必须在实施记录里写清哪条没能复现**,不要默认「三条都复现」。文件级裁剪是否触发依赖 Paimon 的统计与读取路径(native raw-file 读 vs JNI 读),分文件写入是为了让裁剪确定触发;若仍不复现,改用 JNI 读路径的表(例如带 deletion vector 的表)再试一次。 + +**第二步:单元测试**(`PaimonPredicateConverterTest`)。断言的是「下推出来的 Paimon 谓词是什么」,不需要集群: + +| 输入模式串 | 期望 | +| --- | --- | +| `abc%` | 产出前缀 `abc` 的 `startsWith` | +| `abc%%` | 产出前缀 `abc` 的 `startsWith` | +| `a_c%` | **不下推**(转换结果为空列表) | +| `a\%%` | **不下推** | +| `a\_c%` | **不下推** | +| `a%b%` | **不下推** | +| `%abc%` / `%abc` / `abc` | 不下推(现状行为,作为回归护栏钉住) | +| `%` | 不下推 | + +测试要按 Rule 9 的要求把「为什么」写进注释:断言的不是「返回 null」这个实现细节,而是「翻不精确时必须放弃下推,因为收窄会让 Paimon 跳过文件而 BE 补不回来」。 + +**变异验证**(推荐做,成本很低):把新方法里的 `_` 检查单独注释掉,确认 `a_c%` 那条单测转红;再恢复。证明这些测试不是「永远绿」的空壳。 + +**编译门禁**:全反应堆含测试源的 test-compile 是最强单一信号。 + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile -Dmaven.build.cache.enabled=false +``` + +不得使用任何跳过测试编译的参数。跑单测: + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test -pl fe-connector/fe-connector-paimon -am \ + -Dtest=PaimonPredicateConverterTest -DfailIfNoTests=false -Dmaven.build.cache.enabled=false +``` + +`-Dmaven.build.cache.enabled=false` 不是可选项:不加它 surefire 会被 build cache 静默跳过,日志出现 `Skipping plugin execution (cached): surefire:test`,此时 `BUILD SUCCESS` 是空的(见 `plan-doc/HANDOFF.md` 构建坑第 1 条)。另外注意 `mvn ... | tail` 之后的 `$?` 是 `tail` 的退出码,要读日志里的 `BUILD SUCCESS` / `BUILD FAILURE` 行。 + +## 七、风险与回退 + +- **功能风险:低。** 改动只把「下推」变成「不下推」,不下推的语义永远是安全的(BE 侧仍有原始 `LIKE` 过滤),不会产生错误结果。 +- **性能风险:小而真实。** 原本被错误下推的那几种模式串(`_`、转义、中间 `%`)今后不再裁剪文件,这类查询会多读数据。这是把「快但错」换成「慢但对」,方向上没有争议;受影响的只是这三类模式串,最常见的 `'前缀%'` 形态完全不受影响。 +- **兼容性风险:无。** 不涉及 Gson 持久化类型标签、不涉及 thrift 有线格式、不改公共接口签名,插件独立打包也不影响 fe-core。 +- **回退**:单文件单方法改动,`git revert` 即可。端到端用例可独立保留(它断言的是正确行为,回退生产代码后会转红,这正是它该做的)。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` 11.1 节第(4)条:本任务的出处,含「行为后果是代码路径推断、未跑端到端验证」的原始标注,以及「(3)(4)是 paimon 连接器的问题,且与上游既有实现完全相同」的归责结论。 +- 同一节第(3)条:paimon 把空值安全比较 `列 <=> 5` 下推成「该列 IS NULL」,同样是「不可精确翻译却收窄」的错误,同样在 `PaimonPredicateConverter` 里。**是另一份任务**,但如果两个任务由同一人连着做,可以合并成一次端到端回归跑。 +- 同一根因的第二处(本任务范围之外):`fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsQueryDslBuilder.java:512-522` 把 Doris 的 `REGEXP` 模式串原样交给 ES 的 `regexp` 查询(`:522` 调 `regexpQuery`,`:661` 是其实现)。Doris 的 `regexp` 是部分匹配、Lucene 的 `regexp` 是整串锚定,语义不同。值得一提的是 ES 那侧本来就有干净的「拒绝下推」机制(`notPushDownList`),修起来有落点。 +- audit-report.md 第十五节整治路线表第 6 项 —— 修四个真实缺陷的排期,把这批缺陷归为一组,理由是「有用户可见后果(其中三条会静默少行),不应排在设计整治后面」。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/04-fix-trino-struct-field-names-lost.md b/plan-doc/connector-public-interface-cleanup/tasks/04-fix-trino-struct-field-names-lost.md new file mode 100644 index 00000000000000..842be020d00e80 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/04-fix-trino-struct-field-names-lost.md @@ -0,0 +1,237 @@ +# 04. 修复 trino 连接器丢弃复杂类型字段名导致引擎编造 col0 / col1 + +> **优先级**:第一优先级(正确性缺陷,用户拿不到真实字段名、按名访问子字段直接报错) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe/fe-connector/fe-connector-trino`(主修 + 单元测试)、`fe/fe-connector/fe-connector-api`(`ConnectorType` 加构造期校验与契约文档 + 新增单元测试)。**不动** `fe-core`。 +> **预计改动规模**:生产代码 2 个文件,净增约 45 行;测试 2 个文件(1 个新建),约 130 行。可选的端到端用例 1 个 groovy 文件,约 40 行。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +trino 连接器把 Trino 的 ROW 类型翻译成 Doris 类型时只带了每个字段的**类型**、丢掉了每个字段的**名字**,引擎侧拿到一个「有子类型、没字段名」的 STRUCT 后不报错,而是按下标编造 `col0` / `col1` 顶上去;用户于是在 `DESCRIBE` 里看到假名字,并且**没有任何办法按真实字段名访问子字段**。本任务一方面把 trino 侧的字段名带上,另一方面在公共接口 `ConnectorType` 的构造期把「字段名列表必须与子类型列表等长同序」这个到今天为止既无文档、也无校验的不变量变成硬约束,让下一个连接器无法再犯同样的错。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 类型是怎么从连接器流到引擎的 + +连接器不认识 Doris 的 `Type`,它只用公共接口里的 `ConnectorType`(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java`)描述一个列的类型;引擎侧再由 `fe/fe-core/src/main/java/org/apache/doris/datasource/connector/converter/ConnectorColumnConverter.java` 把它翻成 Doris 的 `Type`。 + +`ConnectorType` 描述复杂类型的方式是**多条平行列表**(`:54-84`):`children` 存子类型,`fieldNames` 存 STRUCT 的字段名,另外还有三组可选的按子元素元数据(`childrenNullable` / `childrenComments` / `childrenFieldIds` / `childrenCommentSpecified`)。类里一共 7 个公开构造器(`:86-148`)——6 个便捷构造器(`:86`、`:91`、`:96`、`:102`、`:108`、`:115`)加 1 个规范构造器(`:123`),便捷构造器全部层层委派到最长的那个规范构造器(`:123-148`),而**这个规范构造器只做了 `typeName` 的非空检查和不可变包装,对任何一条列表的长度都不做校验**。 + +工厂方法是给这些平行列表兜底的(`:162-215`):`arrayOf` 保证 1 个子类型、`mapOf` 保证 2 个、`structOf(names, fieldTypes, ...)` 强迫调用方同时给出名字和类型。除 trino 与 es 之外的连接器都走工厂:`HmsTypeMapping.java:150`、`HudiTypeMapping.java:220`、`IcebergTypeMapping.java:89`、`MCTypeMapping.java:135`、`PaimonTypeMapping.java:200`,全部是 `structOf(names, types, ...)`,且 names 与 types 在同一个循环里成对填充。es 只用裸构造器包 ARRAY,且传的是 `Collections.singletonList(type)`(`EsTypeMapping.java:182-183`),个数恰好正确。 + +### 2.2 出问题的地方:trino 侧丢名 + +`fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoTypeMapping.java:107-112`: + +```java +} else if (type instanceof RowType) { + List children = new ArrayList<>(); + for (RowType.Field field : ((RowType) type).getFields()) { + children.add(toConnectorType(field.getType())); + } + return new ConnectorType("STRUCT", -1, -1, children); +} +``` + +循环里只 `add(field.getType())`,`field.getName()` 从头到尾没被读过(Trino 435 的 `RowType.Field` 提供 `Optional getName()`,trino 版本见 `fe/pom.xml:416`);返回时用的是 4 参数裸构造器,`fieldNames` 槽位空缺。同一方法里相邻的 ARRAY(`:92-97`)与 MAP(`:98-106`)分支也用裸构造器,只是子类型个数恰好对,没有暴露出问题。 + +这个映射有两个消费点,都在 `TrinoConnectorDorisMetadata.java`:`:213` 是表结构(`DESCRIBE` / 查询分析看到的列类型),`:366` 是投影下推时回报给引擎的表达式类型。 + +### 2.3 引擎侧的宽容:缺名就编造 + +`ConnectorColumnConverter.java:258-273`: + +```java +private static Type convertStructType(ConnectorType ct) { + List children = ct.getChildren(); + List fieldNames = ct.getFieldNames(); + ArrayList fields = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + String fieldName = i < fieldNames.size() ? fieldNames.get(i) : "col" + i; + ... +``` + +`:263` 这一行就是「编造」:名字缺了就用 `col` + 下标,**不告警、不抛错**。同一个文件里 ARRAY 与 MAP 也是同样的宽容风格:子类型列表为空时 `convertArrayType`(`:242-248`)返回 `ARRAY`、`convertMapType`(`:250-256`)在子类型不足 2 个时返回 `MAP`,一样没有任何提示。 + +同类的「缺名就编造」兜底在反方向(`ConnectorType` → 数据源类型)的连接器代码里还有四处:`HmsTypeMapping.java:239`、`IcebergSchemaBuilder.java:137`、`PaimonTypeMapping.java:289`、`MCTypeMapping.java:212`。也就是说这套「平行列表可以对不齐、对不齐就猜」的风格已经在五个地方复制过。 + +### 2.4 两条补充事实 + +- **这是迁移引入的回退,不是历史一直如此。** 迁移前 fe-core 里的老实现 `TrinoConnectorExternalTable.trinoConnectorTypeToDorisType()`(`git show master:fe/fe-core/src/main/java/org/apache/doris/datasource/trinoconnector/TrinoConnectorExternalTable.java`,ROW 分支)明确判断 `field.getName().isPresent()`,有名字就 `new StructField(name, childType)`,没名字才退化。所以真实字段名在迁移前是能被用户看到的。顺带一个细节:老实现对匿名字段用的是 `new StructField(childType)`,而 `fe/fe-type/src/main/java/org/apache/doris/catalog/StructField.java:46,69-70` 把这种字段一律命名为 `"col"`——多个匿名字段会重名,这本身是老实现的缺陷。 +- **现有单元测试断不出这个缺陷。** `fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTypeMappingTest.java:123-133` 的 `testStructCarriesFieldTypes` 用 `RowType.field("a", INTEGER)` / `RowType.field("b", VARCHAR)` 造了带名字的 ROW,却只断言 `getChildren()` 的两个子类型名,从不看 `getFieldNames()`——名字丢没丢它都是绿的。 + +## 三、为什么这是个问题 + +**用户能观察到的后果(正确性)**:Doris 的 STRUCT 子字段访问是**按名字**解析的。`fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/scalar/ElementAt.java:142-147`(struct 字段访问的类型推导)在按名字找不到字段时抛 `AnalysisException: the specified field name <名字> was not found: ...`。字段名一旦被换成 `col0` / `col1`,用户对着自己在 Trino / Hive 里定义的字段名写查询,一律在分析期就被拒;`DESCRIBE` 也只会显示编造出来的名字,用户连"真名是什么"都查不到。唯一还能用的访问方式是按序号(`s[1]`),而这恰好掩盖了问题——冒烟测试如果只用序号访问,看起来一切正常。 + +**公共接口层违反的设计原则**: + +1. **平行列表的对应关系是不变量,却既没有契约也没有校验。** `ConnectorType` 的类文档(`:25-53`)花了大段篇幅说明 `childrenNullable` / `childrenComments` / `childrenFieldIds` 三组可选元数据「parallel to children」以及为什么它们不参与 `equals`,但**对 `fieldNames` 与 `children` 的对应关系一个字都没写**,`getFieldNames()`(`:245-247`)甚至没有 javadoc;7 个构造器(6 个便捷 + 1 个规范,`:86-148`)也没有一处校验长度。于是"名字和类型必须等长同序"完全靠调用方自觉。 +2. **违约被静默吸收,而不是 fail loud。** 一个连接器写错了,代码能编译、表能加载、`DESCRIBE` 有输出,错误一路潜行到用户写查询的那一刻才以「字段不存在」的形式冒出来,而那时报错现场已经离真正的错误点(连接器的类型映射)很远了。 +3. **同一个坑对 ARRAY / MAP 一样敞开。** ARRAY 需要 1 个子类型、MAP 需要 2 个,公共接口同样不校验,引擎同样静默产出 `ARRAY` / `MAP`(`ConnectorColumnConverter.java:242-256`)。今天没人踩只是因为工厂方法恰好被大多数连接器用了。 + +另外值得注意的一点:`fieldNames` 是**参与** `equals` / `hashCode` 的(`ConnectorType.java:313-333`),也就是说字段名在这个类的设计里属于「类型的结构身份」,不是可有可无的附加元数据。丢名不是"少带了点信息",是构造出了一个身份就不对的类型。 + +## 四、用一个最小例子说明 + +假设 Trino 侧(比如 trino-connector 挂 hive)有这么一张表: + +```sql +-- 数据源侧的表定义 +CREATE TABLE t (id int, s row(a int, b varchar)); +``` + +在 Doris 里通过 trino 连接器查它: + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +| --- | --- | --- | +| `DESC t;` | `s` 显示为 `struct` | `s` 显示为 `struct` | +| `SELECT s.a FROM t;` | 分析期报错 `the specified field name a was not found` | 正常返回 `a` 列的值 | +| `SELECT s['b'] FROM t;` | 同上,按名访问全军覆没 | 正常返回 | +| `SELECT s[1] FROM t;` | 恰好能跑(按序号访问) | 恰好能跑(行为不变) | + +公共接口这一侧的问题,用两行就能说明白: + +```java +// 今天:编译通过、构造成功、什么都不报,直到用户按名字查子字段才炸 +new ConnectorType("STRUCT", -1, -1, Arrays.asList(intType, strType)); // 名字全丢 +new ConnectorType("STRUCT", -1, -1, Arrays.asList(intType, strType), List.of("a")); // 名字只给一半 + +// 期望:上面两行都在构造点立刻抛 IllegalArgumentException +``` + +## 五、解决方案 + +### 5.1 目标状态 + +**(a) trino 侧把名字带上,并改用工厂方法。** `TrinoTypeMapping.toConnectorType` 的 ROW 分支改成: + +```java +} else if (type instanceof RowType) { + List rowFields = ((RowType) type).getFields(); + List names = new ArrayList<>(rowFields.size()); + List types = new ArrayList<>(rowFields.size()); + for (int i = 0; i < rowFields.size(); i++) { + RowType.Field field = rowFields.get(i); + // Trino ROW fields may be anonymous (RowType.anonymousRow); name them by position so that + // every field still gets a distinct, resolvable name. + names.add(field.getName().orElse("col" + i)); + types.add(toConnectorType(field.getType())); + } + return ConnectorType.structOf(names, types); +} +``` + +匿名字段的处理要写清楚:**用 `col` + 下标,而不是复刻老实现给所有匿名字段同名 `"col"` 的做法**。理由是重名字段在 Doris 侧一样无法按名访问,属于老实现的缺陷;`col` + 下标与引擎兜底(`ConnectorColumnConverter.java:263`)以及其它四个连接器的反向兜底命名完全一致,是本仓库既有约定。这是本任务唯一一处有意偏离迁移前行为的地方。 + +ARRAY / MAP 两个分支顺带改成 `ConnectorType.arrayOf(...)` / `ConnectorType.mapOf(...)`(等价改写,只为让「复杂类型一律走工厂」在这个文件里成为一眼可见的事实)。 + +**(b) 公共接口加构造期校验。** 在 `ConnectorType` 的规范构造器(`:123-148`)末尾调用一个新的私有静态方法,签名草案: + +```java +/** + * Fail loud on a malformed complex type: the parallel lists carried alongside {@link #getChildren()} + * must line up with it, and the three complex type tags have a fixed arity. + */ +private static void validateShape(String typeName, List children, List fieldNames, + List childrenNullable, List childrenComments, + List childrenFieldIds, List childrenCommentSpecified) +``` + +校验规则(`typeName` 用 `toUpperCase(Locale.ROOT)` 比对,与 `ConnectorColumnConverter.convertType` 的 `:229` 一致,避免 `"Struct"` 这种拼写绕过校验): + +| 类型标签 | 规则 | +| --- | --- | +| `ARRAY` | `children.size() == 1` | +| `MAP` | `children.size() == 2` | +| `STRUCT` | `children` 非空;`fieldNames.size() == children.size()`;`fieldNames` 中不含 `null` | +| 以上三者 | 四组可选元数据列表(nullable / comments / fieldIds / commentSpecified)每一条要么为空(表示未携带),要么长度恰好等于 `children.size()`;比 `children` 长一定是调用方错了 | +| 其它标签 | **不校验**。`typeName` 是无词表的裸字符串,不能反过来断言「非复杂类型一定没有子类型」 | + +异常一律用 `IllegalArgumentException`(与同一构造器里 `Objects.requireNonNull` 的失败风格一致),消息里带上 `typeName` 和实际长度,例如 `STRUCT field name count (1) must match child type count (2)`。因为所有构造器与工厂方法都汇聚到这个规范构造器,一处落地即全覆盖。 + +**(c) 把契约写进类文档。** 在 `ConnectorType` 类 javadoc(`:25-53`)里补一段说明:`fieldNames` 与 `children` 是等长同序的平行列表、STRUCT 必须携带全部字段名、三个复杂类型标签的子类型个数固定、四组可选元数据要么不带要么带全,并注明这些在构造期强制。同时给 `getFieldNames()`(`:245-247`)补一行 javadoc 指向该契约。 + +**实施顺序**:(a) 必须与 (b) 在同一次改动里落地,且先改 trino——否则先加校验会让 trino 的表加载与现有 `TrinoTypeMappingTest` 立刻抛异常。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +| --- | --- | +| `fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoTypeMapping.java`(ROW 分支 `:107-112`,ARRAY `:92-97`,MAP `:98-106`) | ROW 分支收集字段名并改用 `ConnectorType.structOf(names, types)`,匿名字段用 `col` + 下标;ARRAY / MAP 改用 `arrayOf` / `mapOf`。加注释说明匿名字段命名的来由 | +| `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorType.java`(规范构造器 `:123-148`、类 javadoc `:25-53`、`getFieldNames()` `:245-247`) | 新增私有 `validateShape(...)` 并在规范构造器末尾调用;补齐平行列表契约文档 | +| `fe/fe-connector/fe-connector-trino/src/test/java/org/apache/doris/connector/trino/TrinoTypeMappingTest.java`(`:123-133`) | 扩写 `testStructCarriesFieldTypes` 断言字段名;新增匿名 ROW、嵌套 ROW 两个用例 | +| `fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorTypeTest.java`(新建) | 覆盖 5.1(b) 的每条校验规则,正反两向 | +| `regression-test/suites/external_table_p0/trino_connector/hive/`(可选) | 补一个 STRUCT 列的端到端用例,见第六节 | + +### 5.3 明确不要顺手做的事 + +- **不要动 `equals` / `hashCode`**(`:313-333`)。三组按子元素元数据被有意排除在类型身份之外,类文档 `:39-46` 已写明理由(类型身份等于结构形状,可空性与注释由消费方逐字段单独比较),改它会波及所有基于类型相等的模式变更检测。 +- **不要动 fe-core 的兜底分支**(`ConnectorColumnConverter.java:263` 的 `col` + 下标、`:242-256` 的 `ARRAY` / `MAP`)。三个理由:本阶段 fe-core 只出不进;构造期校验落地后这些分支已经不可能被合法构造的 `ConnectorType` 触发,留着当防御;把它们改成抛异常会把「某一列显示退化」升级成「整张表加载失败」,风险大于收益。 +- **不要顺手清理另外四处反方向的缺名兜底**(`HmsTypeMapping.java:239`、`IcebergSchemaBuilder.java:137`、`PaimonTypeMapping.java:289`、`MCTypeMapping.java:212`)。它们在 `ConnectorType` → 数据源类型的方向上,输入来自 fe-core 的转换器,与本缺陷不是同一条路径;逐一改属于扩大范围。 +- **不要给 ARRAY / MAP 发明 `fieldNames` 语义**。今天没有任何生产者给它们传名字,校验里也只要求「STRUCT 必须带全名字」,不去规定 ARRAY / MAP 必须为空——留出余地,但不主动定义新语义。 +- **不要把这 7 个构造器重构成 builder**,也不要给 `ConnectorType` 加新的工厂重载。校验落在唯一的规范构造器上就够了,重构会波及全部连接器。 +- **不要写 shell / 正则静态门禁**去检查「有没有人用裸构造器造 STRUCT」。本仓库已有结论:这类门禁只适合存在性与前缀类不变量,语言语义交给构造期校验加单元测试。 + +## 六、怎么验证 + +### 单元测试要断言什么 + +`TrinoTypeMappingTest`(扩写与新增): + +1. **带名 ROW**:`RowType.rowType(RowType.field("a", INTEGER), RowType.field("b", VARCHAR))` 转换后 `getFieldNames()` 必须等于 `["a", "b"]`,且与 `getChildren()` 等长同序。**改代码前先跑这条,它必须是红的**——这是本任务的变异验证要求:如果它在旧代码上就绿,说明用例没打到缺陷。 +2. **匿名 ROW**:`RowType.anonymousRow(INTEGER, VARCHAR)`(Trino 435 提供)转换后名字为 `["col0", "col1"]`,锁住匿名字段各自拿到互不相同的名字这一决定。 +3. **嵌套**:`row(a int, b row(c int))` 转换后内层 STRUCT 的字段名也必须是 `["c"]`,证明递归路径同样带名。 +4. 现有的 ARRAY / MAP 用例(`:105-121`)保持不改,作为改用工厂方法后行为未变的基线。 + +新建 `ConnectorTypeTest`(`fe-connector-api`): + +1. STRUCT 名字数少于子类型数、多于子类型数,两向都抛 `IllegalArgumentException`;断言消息里同时出现两个长度(否则报错对定位没帮助)。 +2. STRUCT 子类型列表为空、`fieldNames` 含 `null` 元素,抛异常。 +3. ARRAY 传 0 个或 2 个子类型、MAP 传 1 个或 3 个,抛异常。 +4. 任一组可选元数据列表比 `children` 长时抛异常;**为空时必须仍然合法**(`HudiTypeMapping` / `MCTypeMapping` 的 `structOf(names, types)` 就走这条路,`ConnectorColumnConverterTest.java:446-453` 也依赖它)。 +5. 大小写不敏感:`new ConnectorType("struct", -1, -1, children)`(无名字)同样抛异常,证明校验不能被拼写绕过。 +6. 合法路径全部不抛:`structOf` 的三个重载、`arrayOf` 两个重载、`mapOf` 两个重载,以及 `withChildrenFieldIds` 在 ARRAY(1 个 id)/ MAP(2 个 id)/ STRUCT(N 个 id)上的调用——后者对应 `IcebergTypeMapping.java:66-67,75-76,89` 的真实用法。 +7. 非复杂类型标签不受影响:`ConnectorType.of("JSONB")`、`of("DECIMALV3", 10, 2)` 正常。 + +### 现成的回归网 + +新校验最有价值的副作用是:所有构造复杂类型的既有测试都会变成它的回归网。这几个必须仍然全绿——`fe-core` 的 `ConnectorColumnConverterTest`(大量 `structOf` / `arrayOf` / `mapOf` + `withChildrenFieldIds`),以及 `HmsTypeMappingTest`、`HudiTypeMappingTest`、`HudiSchemaParityTest`、`IcebergTypeMappingReadTest`、`IcebergSchemaBuilderTest`、`IcebergNestedColumnEvolutionTest`、`MCTypeMappingTest`、`PaimonTypeMappingReadTest`、`PaimonTypeMappingToPaimonTest`、`HiveConnectorMetadataSchemaTest`。 + +### 命令 + +单模块测试(**必须禁用 maven build cache**,否则 surefire 会被静默跳过而报 BUILD SUCCESS): + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml \ + -pl fe-connector/fe-connector-api,fe-connector/fe-connector-trino -am \ + -Dmaven.build.cache.enabled=false \ + -Dtest=TrinoTypeMappingTest,ConnectorTypeTest -DfailIfNoTests=false test +``` + +编译门禁(最强的单一信号,验收必跑;`ConnectorType` 加了硬校验,要靠全反应堆确认没有别处的生产或测试源在构造不合法的复杂类型): + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile -Dmaven.build.cache.enabled=false +``` + +不要加任何跳过测试编译的参数。checkstyle 会扫测试源,新建文件要过。 + +### 端到端回归(可选,需要外部环境) + +`regression-test/suites/external_table_p0/trino_connector/hive/` 与 `external_table_p2/trino_connector/` 下**目前没有任何 STRUCT 列的用例**(已 grep 确认),所以这个缺陷此前不可能被端到端拦住。补一条最有价值:建一张带 `struct` 列的 hive 表(`test_trino_prepare_hive_data_in_case.groovy` 是"用例内建表"的现成模板),断言 `DESC` 输出的字段名以及 `SELECT s.a` 能跑通。这一步需要 docker 外部环境,本 session 通常跑不了——补了用例后如实标注「未在本地执行」,不要声称已通过。单元测试已经覆盖转换逻辑本身。 + +## 七、风险与回退 + +- **总体风险低。** trino 侧是一个 `else if` 分支内部的改写,方向是「补上本该带的信息」,不改变任何类型的形状与个数;`ConnectorType` 侧是纯前置校验,合法调用路径的行为完全不变。 +- **主要风险是新校验硬抛异常。** 已逐一核对全部生产侧构造点:只有 `TrinoTypeMapping`(本次修)与 `EsTypeMapping.java:182-183`(ARRAY 单子类型,合法)用裸构造器,其余连接器一律走工厂且名字与类型在同一循环成对填充;四组可选元数据的现有传参也都是等长或为空。测试源里的构造点由全反应堆 `test-compile` 加上第六节列出的既有测试兜住。若后续有人构造出对不齐的复杂类型,会在构造点立刻抛错而不是静默编造名字——这是刻意选择。 +- **用户可见变化:trino 目录下 STRUCT 列的字段名会从 `col0` / `col1` 变成真实名字。** 外部表结构不落盘持久化,升级后重新加载即生效,无需元数据迁移。如果有人此前把 `col0` 写进了查询或视图,那些写法会失效——但它们本来就是在依赖一个缺陷,并且真实名字恰好叫 `col0` 的情况仍然正常工作。 +- **回退**:两处改动互相独立,各自都是单文件局部改写,直接 revert 即可。注意若只 revert trino 侧而保留校验,trino 目录的 STRUCT 列会在表加载时抛异常——要回退就一起回退。 + +## 八、相关背景 + +- 调研报告 `plan-doc/connector-public-interface-cleanup/audit-report.md`: + - `### 11.1 四个有实际用户可见后果的缺陷` 第(2)条 —— 复杂类型字段名被丢弃,本缺陷的摘要条目; + - `### A.6 实现与接口定义不符(一致性)(24 条)` 第 116 条 —— 平行列表无契约无校验,原始判定与位置。 + - 同一章的第 124 条记录了 `ConnectorType.typeName` 的另一个问题(javadoc 说是「连接器自己的类型系统」,实际必须用 Doris 内部拼写,未知名字静默退化为 `UNSUPPORTED`)。它与本任务同在 `ConnectorType`,但**不在本任务范围内**:那是词表与文档问题,本任务只处理复杂类型的形状校验,不要混在一起改。 +- 相邻任务:`tasks/01-fix-trino-or-predicate-row-loss.md` 同样同时改 `fe-connector-trino` 与 `fe-connector-api`,但改的是 `TrinoPredicateConverter` 与 `ConnectorOr`,与本任务无文件重叠,两者可独立进行;两者采用同一种「在公共接口构造期 fail loud」的修法,实施时可互相参照措辞风格。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/05-fix-hudi-partition-timestamp-unit.md b/plan-doc/connector-public-interface-cleanup/tasks/05-fix-hudi-partition-timestamp-unit.md new file mode 100644 index 00000000000000..71f97c3a17c4ff --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/05-fix-hudi-partition-timestamp-unit.md @@ -0,0 +1,201 @@ +# 05. 修复 hudi 在「最后修改时间」字段里填时刻串导致查询缓存永久失效 + +> **优先级**:第一优先级(公共契约违约;表现为性能缺陷,不产生错误结果) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe/fe-connector/fe-connector-hudi`(主改动 + 单元测试);`fe/fe-connector/fe-connector-api`(**仅补一段 javadoc**,不改签名);`regression-test`(新增一个端到端用例)。**不动** `fe-core`。 +> **预计改动规模**:生产代码 2 个文件、约 40 行;单元测试 1 个文件、约 60 行;端到端用例 1 个 groovy 文件、约 70 行。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +公共接口 `ConnectorPartitionInfo.getLastModifiedMillis()` 约定的单位是「epoch 毫秒」(1970 年以来的毫秒数,当前约 1.7×10¹²),hudi 连接器往这个字段里填的却是 Hudi 自己的时刻串 `yyyyMMddHHmmssSSS` 当成数字(约 2.0×10¹⁶,比墙上时钟大四个数量级)。引擎会拿这个值与当前时间相减来判断「这张表最近有没有在写」,减出来的值恒为 0,于是**分区 hudi 表(以及任何与它一起扫的查询)的 SQL 结果缓存永远不会启用**。本任务在连接器侧把时刻转成真正的 epoch 毫秒,引擎零改动。 + +## 二、背景:现在的代码是怎么写的 + +**(1)契约方。** `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java:166-169`: + +```java +/** @return last-modified epoch millis, or {@link #UNKNOWN}. */ +public long getLastModifiedMillis() { + return lastModifiedMillis; +} +``` + +契约就这一句:epoch 毫秒,或者 `UNKNOWN`(`-1`,见 `:32`)。 + +**(2)hudi 连接器怎么填的。** 分区列举的公共收集点是 `HudiConnectorMetadata.collectPartitions`(`fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java:716`),它有两条支路,都把「最新已完成 instant」当作最后修改时间往下传: + +- 走 HMS 同步分区时(`:733`):`return buildPartitionInfos(hmsNames, partKeyNames, latestInstant(handle));` +- 走 hudi 自己的元数据列举时(`:742-749`):`HudiScanPlanProvider.latestCompletedInstant(metaClient)` 的结果作为 `Map.Entry` 的 key 传下去。 + +落笔的地方是 `buildPartitionInfos`(`:759-778`),第 775 行就是那个参数: + +```java +result.add(new ConnectorPartitionInfo(name, values, Collections.emptyMap(), + ConnectorPartitionInfo.UNKNOWN, ConnectorPartitionInfo.UNKNOWN, + instant, ConnectorPartitionInfo.UNKNOWN, // <- 第 775 行,这里应该是 epoch 毫秒 + orderedValues, Collections.emptyList())); +``` + +而 `instant` 是什么,`HudiScanPlanProvider.java:715-717` 与 `:725-727` 写得很清楚:从 timeline 取最新已完成 instant 的 `requestedTime()`(形如 `20240101120000000`),再 `Long.parseLong` 成 `long`;空 timeline 返回 `0L`。**它是一个把年月日时分秒毫秒直接拼起来的数字,不是时间戳。** + +**(3)引擎怎么用这个值。** 只有一个消费点:`fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java:279` 把每个分区的值收进 `nameToLastModifiedMillis`,随后两个方法读它(hudi 既不声明 last-modified 新鲜度、也不提供 range 视图,因此两个方法都落到最后那条兜底分支): + +- `getNewestUpdateVersionOrTime()`(`:803`,兜底分支 `:829`)——数据版本令牌,用于判断「表变没变」; +- `getNewestUpdateTimeMillisForCache()`(`:834`,兜底分支 `:848`)——**只**给 SQL 缓存的「安静窗口」门禁用,方法 javadoc(`MTMVRelatedTableIf.java:119-131`)明确写了它必须是「genuine WALL-CLOCK epoch-millis」。 + +门禁本体在 `fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java:263-277`: + +```java +long newestUpdateMillis = 0; +for (CacheTable cacheTable : tblTimeList) { // 所有被扫的表取最大值 + newestUpdateMillis = Math.max(newestUpdateMillis, cacheTable.latestPartitionUpdateMillis); +} +if (now == 0) { + now = nowtime(); // System.currentTimeMillis() + now = Math.max(now, newestUpdateMillis); // :273 +} +if (enableSqlCache() + && (now - newestUpdateMillis) >= Config.cache_last_version_interval_second * 1000L) { +``` + +`cacheTable.latestPartitionUpdateMillis` 正是 `getNewestUpdateTimeMillisForCache()` 的返回值(`:512`)。`cache_last_version_interval_second` 默认 30(`fe/fe-common/src/main/java/org/apache/doris/common/Config.java:1426`)。 + +顺带核实两件事,避免把影响面说过宽: + +- 外部表进 SQL 缓存要先打开会话变量 `enable_hive_sql_cache`(默认 false,门在 `fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java:855-857`)。**没打开这个开关时本缺陷不可见。** +- 无分区 hudi 表根本走不到这里:它的分区列举返回空(`HudiConnectorMetadata.java:718-720`),版本令牌算出来是 0,`SqlCacheContext.addUsedTable` 的 `version <= 0` 兜底(`fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java:201-206`)直接把它标成不可缓存。**所以本任务修的是分区 hudi 表。** + +**(4)别的连接器没有这个问题**(已逐个核对 `new ConnectorPartitionInfo(` 的调用点):paimon 填 `partition.lastFileCreationTime()`(毫秒,`PaimonConnectorMetadata.java:1290`),hive / iceberg / maxcompute 走不带统计字段的构造器,全是 `UNKNOWN`。**hudi 是唯一一个填了非毫秒值的连接器。** + +## 三、为什么这是个问题 + +违反的原则是:**公共接口写明了单位的字段,实现方必须按那个单位填**。这不是风格问题——引擎拿它跟墙上时钟做减法,单位错了减出来的数没有任何意义,而且错的方向让缺陷完全隐形。 + +推演一遍第二节那段门禁代码(假设一张 hudi 表最新 instant 是 `20240101120000000`): + +1. `newestUpdateMillis = 20240101120000000`(≈ 2.0×10¹⁶); +2. `now = System.currentTimeMillis()` ≈ 1.7×10¹²,接着 `now = Math.max(now, newestUpdateMillis)` 把 `now` 拉到 20240101120000000; +3. `now - newestUpdateMillis == 0`,永远 `< 30000` → 门禁永不放行。 + +注意第 2 步那个 `Math.max`(它本来是为云上 FE 与元数据服务时钟不一致准备的)把这个缺陷变成**永久性**的:不是「等 30 秒就好」,而是无论过多久都为 0。 + +用户能观察到什么:打开 `enable_hive_sql_cache` 后,`EXPLAIN PHYSICAL PLAN` 里 hudi 查询永远看不到 `PhysicalSqlCache`,重复执行同一条 SQL 每次都完整重扫;更隐蔽的是**一张 hudi 表会污染整条查询**——门禁取所有被扫表的最大值,所以「olap 表 join hudi 表」的查询也一起失去 SQL 缓存。不会算错数据,纯粹是性能损失,但影响面是所有分区 hudi 表的查询。 + +顺带说明这个值的另一个身份:它同时被当作 hudi 的数据版本令牌(`getNewestUpdateVersionOrTime`)与每分区的物化视图新鲜度快照(`MTMVTimestampSnapshot`)。这两处只要求「变了就不同、单调不减」,instant 和 epoch 毫秒都满足,所以那两个功能今天是好的——本任务只把单位改对,不改这两处的语义。 + +## 四、用一个最小例子说明 + +```sql +-- 会话开关:外部表 SQL 缓存的总开关(默认关) +SET enable_sql_cache = true; +SET enable_hive_sql_cache = true; + +-- 一张分区 hudi 表,最后一次写入发生在很久以前(远超 30 秒的安静窗口) +EXPLAIN PHYSICAL PLAN SELECT count(*) FROM hudi_catalog.db.one_partition_tb; +``` + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +| --- | --- | --- | +| 上面这条查询,反复执行 | 门禁算出 `now - 20240101120000000 = 0`,永远不满 30 秒 → 计划里没有 `PhysicalSqlCache`,每次都重扫 | 表已安静很久 → 计划里出现 `PhysicalSqlCache`,第二次执行命中缓存 | +| `SELECT * FROM olap_tbl JOIN hudi_tbl ...` | hudi 表的值参与取最大值,把整条查询的门禁也顶死 | 两张表都安静 → 整条查询可缓存 | +| 刚往 hudi 表写完就查 | 不缓存(碰巧是对的,但理由是错的) | 不缓存(因为真的在 30 秒安静窗口内) | + +单位换算本身就一行事: + +``` +现在填的: 20240101120000000 (2024-01-01 12:00:00.000 这串数字本身) +应该填的: 1704110400000 (同一时刻的 epoch 毫秒,1e12 量级) +判据: |填的值 - System.currentTimeMillis()| 应该是「这张表多久没写」的量级, + 而不是四个数量级的差 +``` + +## 五、解决方案 + +### 5.1 目标状态 + +hudi 连接器在把分区信息交给引擎之前,用 Hudi 自己的工具把 instant 转成 epoch 毫秒;raw instant 继续留在它本来该在的地方(查询快照 `beginQuerySnapshot` 的 `snapshotId`、时间旅行的 handle 属性),不再冒充「最后修改时间」。公共接口只补文档。 + +在 `HudiScanPlanProvider` 里新增两个方法(放在既有 `requestedTimeToInstant`,`:725` 旁边,保持「纯静态、可离线单测」的既有风格): + +```java +/** 表的 instant 是按哪个时区生成的(hoodie.table.timeline.timezone,默认 LOCAL)。 */ +static ZoneId timelineZone(HoodieTableMetaClient metaClient); + +/** + * Hudi instant(yyyyMMddHHmmssSSS 数字)-> epoch millis,即 + * ConnectorPartitionInfo#getLastModifiedMillis 契约要求的单位。instant <= 0(空 timeline)返回 0。 + */ +static long instantToEpochMillis(long instant, ZoneId zone); +``` + +实现建议(这四个 API 已用 `javap` 在 `hudi-common:1.0.2` 的 jar 里核实存在):`HoodieInstantTimeGenerator.fixInstantTimeCompatibility(String)` 把 14 位的秒级老 instant 补齐成 17 位,`HoodieInstantTimeGenerator.MILLIS_INSTANT_TIMESTAMP_FORMAT`(值为 `"yyyyMMddHHmmssSSS"`)作为格式,`HoodieTableMetaClient.getTableConfig().getTimelineTimezone().getZoneId()` 拿时区。**不要**改用 `HoodieInstantTimeGenerator.parseDateFromInstantTime`:它一行就能用,但时区取自一个全局静态开关,对显式配了 `timeline.timezone=UTC` 的表会引入一个时区偏移量级的偏差(详见第七节)。 + +解析失败时 **log warn + 返回 0**,不要抛:这个值只喂缓存与新鲜度启发式,而它所在的分区列举是查询热路径,为一个统计字段炸掉整张表的查询不划算。返回 0 等于「无可靠变更信号」,引擎侧已有兜底(`SqlCacheContext` 的 `version <= 0` 分支)。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +| --- | --- | +| `fe/fe-connector/fe-connector-hudi/.../HudiScanPlanProvider.java` | 新增 `timelineZone` 与 `instantToEpochMillis`(见 5.1)。javadoc 写清:这是给公共 `lastModifiedMillis` 字段用的单位转换,raw instant 只用于 timeline 定位与快照 id,两者不要混用。 | +| `fe/fe-connector/fe-connector-hudi/.../HudiConnectorMetadata.java` | ① `:742-749` 那个 `metaClientExecutor.execute` 里,把 `Map.Entry` 的 key 从 `latestCompletedInstant(metaClient)` 换成 `instantToEpochMillis(latestCompletedInstant(metaClient), timelineZone(metaClient))`——转换在**同一个已建好的 metaClient 上**完成,零额外远程调用。② 新增 `long latestInstantMillis(HudiTableHandle handle)`(镜像既有 `latestInstant`,`:785-789`),供 HMS 同步支路 `:733` 使用。③ `buildPartitionInfos`(`:759-760`)第三个参数改名 `instant` → `lastModifiedMillis`,并同步修正它的 javadoc(`:752-758` 现在写的是「= the pinned instant」)。 | +| `fe/fe-connector/fe-connector-api/.../ConnectorPartitionInfo.java` | 只改 `getLastModifiedMillis()` 的 javadoc(`:166`):补一句「引擎会拿它与墙上时钟相减做 SQL 缓存的安静窗口门禁(`CacheAnalyzer`),填非 epoch 毫秒的值会**静默关闭**该表及与它同查询的所有表的 SQL 缓存」。签名与字段不动。 | +| `fe/fe-connector/fe-connector-hudi/src/test/.../HudiConnectorPartitionListingTest.java` | 在「item 1: instant string → long」那组(`:58-70`)后面加一组单位转换用例;同时修 `buildPartitionInfosStampsInstantAndValues`(`:139-156`)——它现在断言 instant 原样透传(`:149`),改成传一个毫秒值并断言透传。`:221` 与 `:234` 那两处传 `5L` / `88L` 的用例不受影响(转换已挪到调用点之前,`buildPartitionInfos` 只负责透传)。 | +| `regression-test/suites/external_table_p2/hudi/test_hudi_sqlcache.groovy`(新增) | 端到端:对一张分区 hudi 表断言 `PhysicalSqlCache` 出现。骨架直接抄 `regression-test/suites/external_table_p0/iceberg/test_iceberg_sqlcache.groovy`(那份是同一个门禁上 iceberg 微秒单位问题的回归,`:18-30` 的 WHY 注释、`:45-52` 读 `cache_last_version_interval_second` 自适应等待、`:54-66` 的 `assertHasCache` 断言助手都可以照用);catalog 建法与表名抄 `regression-test/suites/external_table_p2/hudi/test_hudi_partition_prune.groovy:18-52`(`regression_hudi.one_partition_tb`)。 | + +### 5.3 明确不要顺手做的事 + +- **不要给 `ConnectorPartitionInfo` 加「instant 原值」属性键。** 已核实 fe-core 里没有任何地方读 `ConnectorPartitionInfo.getProperties()`(唯一消费 `lastModifiedMillis` 的是 `PluginDrivenMvccExternalTable:279`;`ShowPartitionsCommand:299-312` 只读名字/行数/大小/文件数)。表级 instant 已经由 `beginQuerySnapshot` 的 `snapshotId` 承载(`HudiConnectorMetadata.java:437-449`)。加一个没有消费方的键属于投机。 +- **不要动 fe-core 的任何文件。** 引擎侧已经把「版本令牌」与「安静窗口门禁值」拆成两个方法了(`CacheAnalyzer.java:259-262`、`:505-513` 的注释就是这次拆分留下的),修 hudi 不需要引擎配合。当前阶段 fe-core 只出不进。 +- **不要顺手让无分区 hudi 表也能进 SQL 缓存。** 那要给 hudi 补一条表级新鲜度通道(`getTableFreshness` / range 视图之一),是独立的能力扩展,且无分区 iceberg 表今天同样不可缓存(见 iceberg 那份用例的注释),行为一致,不构成缺陷。 +- **不要改 `beginQuerySnapshot` 里的 `snapshotId`。** 它必须保持 raw instant:物化视图的表级快照与时间旅行都靠它,换成毫秒会打断与 timeline 的对应关系。 +- **不要顺手统一「其它连接器的分区统计字段」**(例如给 hive 补分区最后修改时间)。那是能力增强,与本次单位修复无关。 +- **不要为这类单位约束加静态门禁**(shell/正则)。「某个 long 是不是 epoch 毫秒」不是文本可判定的,本仓库已有结论:这类门禁只适合存在性/前缀类不变量。约束靠 javadoc + 单测钉住。 + +## 六、怎么验证 + +**第一步:单元测试(不需要集群,是本任务的主要证据)。** 加在 `HudiConnectorPartitionListingTest`: + +| 用例 | 断言 | +| --- | --- | +| `instantToEpochMillis(20240101120000000L, ZoneOffset.UTC)` | 等于 `1704110400000L`(2024-01-01T12:00:00Z),并断言落在 `[1e12, 1e13)` 区间——**量级断言就是这次的核心判据** | +| 14 位秒级老 instant(如 `20240101120000L`) | 转换成功且与上面同一天同一小时的量级一致。**不要硬编码猜测毫秒补位**,先跑出来看 hudi 的 `fixInstantTimeCompatibility` 实际补什么(jar 里的常量 `DEFAULT_MILLIS_EXT = "999"`),再把实际值钉进断言 | +| `instantToEpochMillis(0L, zone)` | 等于 `0L`(空 timeline 语义不变,仍是 `>= 0`,能过 `getNewestUpdateVersionOrTime` 的 `v >= 0` 过滤) | +| 不可解析的数字(如 `5L`) | 返回 `0L`,不抛异常 | +| **意图用例(最重要的一条)** | 用「一小时前」的本地时间拼出 instant 串再转换,断言 `System.currentTimeMillis() - 转换值` 落在约 1 小时 ± 5 分钟内。这条用例直接编码了 Rule 9 要求的「为什么」:这个值必须能被「当前时间减去它 = 这张表安静了多久」这个门禁正确解读。**回退到 raw instant 会让这个差值变成大负数,用例转红。** | + +**变异验证(要做,成本极低)**:把 `HudiConnectorMetadata` 里的转换调用改回直接传 instant,确认上面那条意图用例转红;恢复。证明这组测试不是永远绿的空壳。 + +**第二步:端到端回归(需要 hudi 外部环境,`enableHudiTest=true`)**。新增用例 `test_hudi_sqlcache`:开 `enable_sql_cache` 与 `enable_hive_sql_cache`,对分区表 `regression_hudi.one_partition_tb` 断言 `EXPLAIN PHYSICAL PLAN` 里出现 `PhysicalSqlCache`。测试环境的 hudi 数据是预置的静态数据,早已超出 30 秒安静窗口,因此**修复前必然红、修复后必然绿**,判据干净。`use_hive_sync_partition` 建议像 `test_hudi_partition_prune` 那样两个取值各跑一遍,因为两条支路的转换点是分开改的(5.2 的 ① 与 ②)。 + +同时回跑既有的 hudi 物化视图相关用例,确认新鲜度语义没被打断(换单位后首次比较会判定「变了」,触发一次多余刷新,属预期):`test_hudi_mtmv`、`test_hudi_rewrite_mtmv`、`test_hudi_olap_rewrite_mtmv`、`test_hudi_partition_prune`。 + +**第三步:编译门禁。** 全反应堆含测试源的 test-compile 是最强单一信号: + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile -Dmaven.build.cache.enabled=false +``` + +不得使用任何跳过测试编译的参数。跑单测: + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test -pl fe-connector/fe-connector-hudi -am \ + -Dtest=HudiConnectorPartitionListingTest -DfailIfNoTests=false -Dmaven.build.cache.enabled=false +``` + +`-Dmaven.build.cache.enabled=false` 不是可选项:不加它 surefire 会被 build cache 静默跳过(日志出现 `Skipping plugin execution (cached): surefire:test`),此时 `BUILD SUCCESS` 是空的。另外 `mvn ... | tail` 之后的 `$?` 是 `tail` 的退出码,要读日志里的 `BUILD SUCCESS` / `BUILD FAILURE` 行。 + +## 七、风险与回退 + +- **正确性风险:无。** 这个字段不参与读数据、不参与分区裁剪,只喂缓存门禁与新鲜度比较。改错了最坏也只是缓存启用早晚,不会出错行。 +- **数据版本令牌一次性变小。** 同一个值也是 hudi 的版本令牌,修复后从 ~2.0×10¹⁶ 掉到 ~1.7×10¹²。唯一会因此报错的地方是 SQL 字典:`Dictionary.hasNewerSourceVersion`(`fe/fe-core/src/main/java/org/apache/doris/dictionary/Dictionary.java:282-286`)在新版本小于已记录版本时抛异常。已核实 `srcVersion`(`:107`)**没有** `@SerializedName`,即不持久化、FE 重启归零,而部署这个改动本身就要重启 FE,所以不存在跨重启的比较。**结论:不构成实际风险,但要在 PR 说明里写明这条推理,别让评审自己去猜。** +- **物化视图会多刷一次。** 每分区的 `MTMVTimestampSnapshot` 值换了单位,物化视图元数据里持久化的上次刷新快照与新值不等,会触发一次刷新。之后恢复稳定。 +- **时区偏差(已按 5.1 的方案规避,这里记录残余)。** instant 串本身不带时区,按 `hoodie.table.timeline.timezone` 生成(默认 `LOCAL`)。5.1 的方案从表配置读时区,所以显式配 `UTC` 的表是准的;配 `LOCAL` 的表按 FE 本地时区解析,若写入方与 FE 不在同一时区,转换值会有一个时区偏移量级的误差。这只会让缓存提前或推迟启用(最坏推迟约一个时区偏移的时长,因为 `now = Math.max(now, newest)` 会把未来值顶住),不影响正确性,且 `LOCAL` 语义本身就是「按写入方本地时区」,Doris 无从得知写入方时区——不要试图猜。 +- **单调性。** instant 到毫秒的映射是同序的(hudi 自己比较 instant 时也把 14 位按补 `999` 处理),因此版本令牌仍然单调不减,字典与物化视图的单调性要求不被破坏。 +- **回退**:改动集中在 hudi 连接器两个文件,`git revert` 即可。端到端用例可以留着(回退后它会转红,这正是它该做的事)。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` 附录 A.6 第 120 条 —— 时刻串冒充 epoch 毫秒:本任务的出处,给出了「契约是 epoch 毫秒 / hudi 填 instant / 把 SqlCache 安静窗口门禁算坏」的判定与符号定位。 +- 同一份报告第十节 10.2(主题七「语义与契约不清」里数值单位那一小节)—— 单位与未知值无统一约定,以及第十五节整治路线表第 6 项 —— 修四个真实缺陷的排期:把它与 trino 三路 OR 丢行、paimon 两处谓词收窄归为同一批,理由是有用户可见后果、不应排在设计整治之后。 +- 同一门禁上已经修过的先例(**动手前建议先读**):`regression-test/suites/external_table_p0/iceberg/test_iceberg_sqlcache.groovy:18-30` 的注释完整记录了 iceberg「微秒喂进毫秒门禁」的同类问题及其修法,`MTMVRelatedTableIf.getNewestUpdateTimeMillisForCache`(`fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelatedTableIf.java:119-131`)与 `ConnectorMvccPartitionView.getNewestUpdateWallClockMillis`(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/mvcc/ConnectorMvccPartitionView.java:126-137`)就是那次拆分留下的接口。iceberg 走的是 range 视图分支,hudi 走兜底分支,所以不能直接复用它的通道——但两者是同一个坑的两种表现。 +- `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java:56` 附近是**同类的第三处**单位违约(契约写字节数、maxcompute 在行偏移模式返回行数,报告条目 121)。是另一份任务,不要塞进本任务,但两者可以共享同一条经验:公共接口凡写了单位的字段,都该有一条量级单测钉住。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/06-fix-engine-context-forwarding-gap.md b/plan-doc/connector-public-interface-cleanup/tasks/06-fix-engine-context-forwarding-gap.md new file mode 100644 index 00000000000000..0bbc6a50702214 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/06-fix-engine-context-forwarding-gap.md @@ -0,0 +1,242 @@ +# 06. 补齐两个连接器对引擎上下文的转发缺口,并根治「加一个方法就漏一次类加载器钉桩」的机理 + +> **优先级**:第一优先级(潜伏事故,非当前可观测的线上错误) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe-connector-spi`(新增一个转发基类 + 一个单测)、`fe-connector-iceberg`、`fe-connector-paimon`。**不改 `fe-core`**。 +> **预计改动规模**:新增 2 个文件(约 200 行,含注释),两个包装类各净减约 80~90 行;合计 4~5 个文件。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +iceberg 与 paimon 各有一个「把线程上下文类加载器钉到插件加载器上」的包装类,它们逐方法手抄转发引擎上下文 `ConnectorContext`,今天各漏抄了 1~2 个方法;漏抄不报编译错、只会静默返回接口默认值(取文件系统的默认值是 `null`)。本任务补齐这两处缺口,并用一个公共转发基类 + 一个反射驱动的单测,把「以后每加一个方法就再漏一次」的机理堵掉。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 引擎上下文的 19 个方法里只有 2 个是抽象的 + +`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java` 是「引擎实现、连接器消费」的接口,共 19 个方法,其中只有 `getCatalogName()`(`:39`)和 `getCatalogId()`(`:42`)是抽象的,其余 17 个都带默认实现,而这些默认值的语义一律是**静默降级**: + +| 方法 | 位置 | 默认行为 | +|---|---|---| +| `sanitizeJdbcUrl` | `:79-81` | 原样返回,不做任何地址安全检查 | +| `executeAuthenticated` | `:98-100` | 直接跑任务,不套任何认证上下文 | +| `getMetaInvalidator` | `:109-111` | 返回空操作 | +| `newStorageUriNormalizer` | `:230-232` | 退化成逐次调用 `normalizeStorageUri`,丢掉「每次扫描只推导一次存储配置」的优化 | +| `getBackendStorageProperties` / `getStorageProperties` / `getBrokerAddresses` | `:314` / `:362` / `:291` | 返回空 | +| **`getFileSystem`** | **`:390-392`** | **返回 `null`** | +| `cleanupEmptyManagedLocation` | `:412-414` | 什么都不做 | + +真正实现它的引擎类只有一个:`fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java:78`。 + +### 2.2 两个包装类为什么存在 + +iceberg 与 paimon 的插件把 `hadoop-common`、`fe-kerberos`(iceberg 还有 `iceberg-aws`)按 child-first 打进插件包,所以插件内部任何**按类名反射**的加载(默认用线程上下文类加载器)如果跑在引擎线程的默认加载器下,就会拿到 fe-core 那一份副本,与插件自己那一份互相 `ClassCastException`。为此两个连接器各有一个装饰器,把 `executeAuthenticated` 包起来,在任务执行期间把线程上下文类加载器钉到插件加载器,Kerberos 目录还额外在插件侧的 `doAs` 里跑: + +- `fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java:74`(`executeAuthenticated` 在 `:98-114`,其余「纯转发」段落在 `:116-210`) +- `fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.java:63`(`executeAuthenticated` 在 `:76-92`,纯转发段落在 `:94-179`) + +装饰器在连接器构造时套一次,之后整个连接器只看得见包装后的上下文:`IcebergConnector.java:225`、`PaimonConnector.java:153`。 + +### 2.3 实测的转发缺口 + +按符号逐个比对(已在 HEAD 上核实): + +| 包装类 | 覆写的 `ConnectorContext` 方法数 | 漏掉的方法 | +|---|---|---| +| iceberg | 18 / 19 | `getFileSystem(ConnectorSession)` | +| paimon | 17 / 19 | `getFileSystem(ConnectorSession)`、`newStorageUriNormalizer(Map)` | + +漏掉的方法会落到接口默认实现上:连接器调 `context.getFileSystem(session)` 拿到的是 `null`,而不是引擎那个按 catalog 缓存的文件系统。 + +### 2.4 hive 已经在真的用引擎文件系统 + +hive 连接器(它**没有**这种包装类,直接持有引擎注入的上下文)已经有 6 个直接调用点:`HiveScanPlanProvider.java:153`、`:157`、`:258`,`HiveConnectorMetadata.java:910`、`:942`,`HiveConnectorTransaction.java:756`;其中 transaction 那一处是私有 helper,内部再扩散到约 19 个读写目录的位置。这个 helper 对 `null` 是失败退出的: + +```java +// HiveConnectorTransaction.java:755-761 +private FileSystem getFileSystem() { + FileSystem engineFs = context.getFileSystem(session); + if (engineFs == null) { + throw new DorisConnectorException("No engine FileSystem available for hive write transaction " + + transactionId + " (catalog has no storage properties)"); + } +``` + +注意这句报错文案把原因归给了「目录没有存储属性」。 + +### 2.5 同一类接口存在三套政策 + +- `ConnectorContext`(引擎实现):2 抽象 + 17 默认; +- `ConnectorValidationContext`(同样是引擎实现,`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorValidationContext.java`):`:34`、`:37`、`:40`、`:50`、`:59`、`:73` 共 6 个方法,**全抽象**; +- `ConnectorProvider`(连接器实现,`fe-connector-spi/.../ConnectorProvider.java`):`:46`、`:64` 抽象,`:52`、`:74`、`:79`、`:84`、`:93` 默认(方向正确)。 + +公共模块里没有任何一处成文规则说明这三套差异从何而来。(把规则写下来是任务 07 的事,本任务只做机理修复。) + +## 三、为什么这是个问题 + +**先把严重性说准**:今天 iceberg 与 paimon 都还没有调用 `context.getFileSystem(...)`(全仓库检索,只有 hive 在用),paimon 也还在用逐次的 `normalizeStorageUri(rawUri, token)`(`PaimonScanPlanProvider.java:735`)而没有用批量归一器。所以这**不是一个当前用户能观测到的错误**,是潜伏缺陷。这一点必须写清楚,避免下一位实施者按「线上故障」去找复现。 + +它真正的问题有三层: + +1. **一旦被使用就是难查的故障。** iceberg/paimon 哪天开始用引擎文件系统(这是本项目明确的方向:连接器不再自带 Hadoop `FileSystem`,由引擎按 scheme 路由),拿到的是 `null`。如果照 hive 的写法做 `null` 检查,用户看到的报错会是「catalog has no storage properties」——**指向一个完全无关的原因**,而目录的存储属性其实是好的;如果没做检查,就是一个空指针。 +2. **paimon 少的那个批量归一器是静默性能回退。** 行为仍然正确,但每个数据文件都会重新推导一遍存储配置(`StorageProperties.createAll` + 一次 hadoop config 构建),把「每次扫描一次」变回「每文件一次」,没有任何日志。 +3. **机理本身是事故源,而且会复发。** 这两个类存在的唯一理由就是钉类加载器。而它们采用「实现接口 + 手抄每个方法」的写法,于是**每往 `ConnectorContext` 加一个带默认实现的方法,这两个类都会默认漏掉那一次转发**,编译器一句话都不会说。本项目已经反复踩过类加载器分裂事故(扫描线程、写/DDL 引擎线程、iceberg 内部 manifest 写线程池、HMS 客户端创建点,四个位置各修过一次),这是同一类事故的下一个入口。 + +## 四、用一个最小例子说明 + +### 例子一:连接器写了一行取文件系统的代码 + +假设 paimon 连接器要清理一个写失败留下的临时目录,于是照 hive 的写法写: + +```java +FileSystem fs = context.getFileSystem(session); // 连接器代码,编译通过 +fs.delete(Location.of(tmpPath), true); +``` + +| 连接器作者写了什么 | 今天实际发生什么 | 应该发生什么 | +|---|---|---| +| `context.getFileSystem(session)` | 调用落到包装类上;包装类没有覆写这个方法 → 走接口默认实现 → **返回 `null`** → 下一行空指针 | 转发到引擎上下文 → 拿到该 catalog 的引擎文件系统 | +| 编译期 | 无任何提示 | 无提示(这也是问题:所以要靠单测兜住) | +| 排错时看到的线索 | 空指针,或者(若模仿 hive 加了检查)「catalog has no storage properties」——把作者引向去查目录属性 | 不该发生 | + +### 例子二:明天给引擎上下文加一个新方法 + +假设某个新需求要在引擎上下文上加一个 `getTableLocationResolver()`,它内部会进插件代码: + +```java +// 有人在 ConnectorContext 里加: +default TableLocationResolver getTableLocationResolver() { return TableLocationResolver.NOOP; } +``` + +作者改了 `DefaultConnectorContext`,跑通了 hive 的端到端用例(hive 不走包装类),提交。**iceberg 与 paimon 从此静默拿到 `NOOP`,且这次调用不再有类加载器钉桩。** 这就是本任务要堵的那条缝:不是这一次谁忘了,而是这个写法保证了以后每次都会忘。 + +## 五、解决方案 + +### 5.1 目标状态 + +在公共模块 `fe-connector-spi` 提供一个转发基类,两个钉桩包装类改为**继承它、只覆写真正需要特殊处理的方法**;再用一个反射驱动的单测钉住「基类必须覆写接口的每一个方法,且每次调用都必须原样到达被包装的上下文」。 + +签名草案(`org.apache.doris.connector.spi.ForwardingConnectorContext`): + +```java +/** + * 装饰 ConnectorContext 的基类:逐方法转发给被包装的上下文。 + * 需要在某个方法上做额外处理(例如把线程上下文类加载器钉到插件加载器)的装饰器, + * 只覆写那个方法,其余方法由本类保证转发。 + * + * 新增 ConnectorContext 方法时必须同时在本类补一个转发, + * ForwardingConnectorContextTest 会强制这件事。 + * 如果新方法会进入插件代码,钉桩子类还必须覆写它并加钉桩。 + */ +public abstract class ForwardingConnectorContext implements ConnectorContext { + + private final ConnectorContext delegate; + + protected ForwardingConnectorContext(ConnectorContext delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + /** 被包装的原始引擎上下文(子类需要绕过自身装饰时用它)。 */ + protected final ConnectorContext delegate() { + return delegate; + } + + @Override public String getCatalogName() { return delegate.getCatalogName(); } + // …… 其余 18 个方法逐一转发,一个不漏 …… +} +``` + +改完之后,两个包装类的正文只剩:构造函数(多传一个插件加载器与认证器)、`executeAuthenticated` 的钉桩实现、iceberg 那个包私有的 `getPluginAuthenticator()`(`:94-96`,写路径要拿它把认证器带进 FileIO,必须保留)。 + +**为什么选转发基类,而不是把方法改成抽象**: + +| 方案 | 代价 | 是否根治 | +|---|---|---| +| 转发基类(推荐) | 新增 1 个公共类;两个包装类各减约 85 行 | 是。补一处即两个包装类同时受益;配合单测,新增方法时会被强制处理 | +| 把「引擎必须履约」的方法(地址消毒、认证包装、取文件系统)改成抽象 | 全仓库有 25 个 `ConnectorContext` 实现(8 个具名 + 17 个匿名),其中 22 个是测试替身(17 个匿名全在测试源,具名测试替身 5 个),只有 3 个是生产实现(`DefaultConnectorContext` 与两个钉桩包装类);改抽象要逐个补空实现,`getFileSystem` 变抽象还会逼每个离线测试替身去编造一个文件系统 | **否**。包装类仍然要为每个新方法手抄一次转发,钉桩照旧会漏;而且以后每加一个抽象方法就一次性打断 25 个实现 | + +所以推荐第一条路;同时**不**顺手改抽象/默认的划分(那属于任务 07 的规则梳理,本任务只在基类的注释里写清「新增方法必须补转发」)。 + +残留风险要写在基类注释里:基类只保证「不丢转发」,不保证「不丢钉桩」。如果新方法会进入插件代码,钉桩子类必须自己覆写并加钉桩——单测失败时的提示语要把这句话直接写出来,让改接口的人当场做这个判断。 + +### 5.2 改动清单 + +| 文件 | 要做什么 | +|---|---| +| `fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ForwardingConnectorContext.java` | 新增。逐方法转发全部 19 个方法;`protected final ConnectorContext delegate()` 暴露原始上下文;类注释写明「新增接口方法必须在此补转发」与「进插件代码的方法还需子类钉桩」 | +| `fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ForwardingConnectorContextTest.java` | 新增。见第六节 | +| `.../fe-connector-iceberg/.../TcclPinningConnectorContext.java` | 改为 `extends ForwardingConnectorContext`;删除 `:116-210` 全部纯转发方法;`executeAuthenticated` 内部把 `delegate.executeAuthenticated(task)` 换成 `delegate().executeAuthenticated(task)`;保留类注释与 `getPluginAuthenticator()`;`createSiblingConnector` 的「必须转发给原始上下文而非本装饰器」语义由基类天然满足,把原有那段解释性注释移到基类或就地保留一句说明 | +| `.../fe-connector-paimon/.../TcclPinningConnectorContext.java` | 同上(`:94-179` 纯转发方法删除,`executeAuthenticated` 同样改用 `delegate()`) | +| 两个连接器已有的 `TcclPinningConnectorContextTest` | 保留现有断言(钉桩、异常时恢复调用方加载器、Kerberos 单一认证方、sibling 转发给原始上下文)。补一条断言:`getFileSystem(session)` 与(paimon)`newStorageUriNormalizer(...)` 的返回值来自被包装的上下文,而不是接口默认值 | + +顺序建议:先加基类与基类单测(此时两个包装类不动,编译通过),再逐个迁移包装类;每一步都能独立编译验证。 + +### 5.3 明确不要顺手做的事 + +- **不要把 `ConnectorContext` 的任何默认方法改成抽象。** 理由见 5.1 的对照表(25 个实现、其中 22 个测试替身)。抽象/默认政策的统一是任务 07 的范围。 +- **不要给 hive 连接器加这种包装类。** hive 直接用引擎注入的上下文,本来就没有这个缺口;plain-hive 的类加载器钉桩在别的位置(HMS 客户端创建点与 `HiveConf` 构造点)已经解决,不要在这里重做一遍。 +- **不要顺手给基类的转发方法加钉桩。** 现有两个包装类明确写了哪些方法不需要钉桩(存储地址归一化、后端连通性探测完全跑在引擎侧)。无差别加钉桩会改变现有行为并带来无谓开销。 +- **不要动 `ConnectorSession` 的默认值问题**(`getStatementScope` 默认不记忆等)。那是同类问题的另一个接口,属于另一条任务,本任务不扩。 +- **不要写 shell / 正则门禁去校验「包装类是否覆写齐全」。** 本仓库已有结论:这类门禁只适合存在性与前缀类不变量,要理解 Java 语义就等于在 shell 里写解析器,误报比漏报更毒。这里用运行时单测。 +- **不要顺手删 `getMetaInvalidator`**。删除推模型失效接口是任务 14,它同样要改这两个包装类;本任务先做完,任务 14 届时只需在基类删一个转发方法。 +- 不要改 `fe-core`:本任务全部改动落在公共模块与两个插件里,符合「fe-core 只出不进」。 + +## 六、怎么验证 + +### 6.1 基类单测:反射 + 动态代理,逐方法断言「原样到达」 + +`ForwardingConnectorContextTest` 的做法(放在 `fe-connector-spi`,与既有的 `ConnectorContextTest` 同目录;该模块已有 `junit-jupiter` 依赖,动态代理用 JDK 自带的 `java.lang.reflect.Proxy`,无需新依赖): + +1. 用 `Proxy.newProxyInstance` 造一个记录型 `ConnectorContext`,记录每次被调用的 `Method`(名字 + 参数类型)与实参,并返回该返回类型的一个可区分的取值(例如文件系统返回一个 `Proxy` 出来的非 `null` 实例、字符串返回带标记的串)。 +2. 造一个空的匿名子类 `new ForwardingConnectorContext(recording) {}`。 +3. 反射枚举 `ConnectorContext.class.getMethods()`,对每个方法用按类型编造的实参调用一次(`ConnectorSession` 传 `null`,接口文档允许;`Callable` 传一个返回标记值的任务)。 +4. 断言:**每次调用都在记录器上留下了同一个方法**(按名字 + 参数类型精确比对,不能只比名字),且实参与返回值原样穿过。 + +这个测法为什么能真正抓住缺陷(逐一对应今天的两个缺口): + +- 少覆写 `getFileSystem` → 走接口默认 → 记录器上没有任何记录 → **失败**; +- 少覆写 `newStorageUriNormalizer` → 接口默认返回一个 lambda、当场不碰被包装对象 → 记录器上没有记录 → **失败**; +- 少覆写 `normalizeStorageUri(String, Map)` → 接口默认转调单参版本 → 记录器上记录的是**单参**方法 → 因为按参数类型精确比对,**失败**(这正是「只比方法名会漏」的那种情况); +- 少覆写 `getBackendFileType` / `testBackendStorageConnectivity` / `cleanupEmptyManagedLocation` → 默认实现自己算或什么都不做 → 无记录 → **失败**。 + +**变异验证(必须做)**:从基类里手工删掉任意一个转发方法,跑该测试,确认它报错并且报错信息指出了是哪个方法;恢复。至少对 `getFileSystem` 和 `normalizeStorageUri(String, Map)` 各做一次。 + +### 6.2 两个连接器的包装类单测 + +保留现有断言不变(这是行为不变的证据):钉桩生效、任务抛异常时恢复调用方加载器、非 Kerberos 走被包装上下文的认证、Kerberos 走插件侧 `doAs` 且不再调被包装上下文的认证、`createSiblingConnector` 转发给原始上下文。各补一条: + +- iceberg:`ctx.getFileSystem(null)` 返回的对象与记录型上下文给出的同一个(今天返回 `null`,改前应先确认这条新断言在旧代码上是失败的)。 +- paimon:同上,并额外断言 `ctx.newStorageUriNormalizer(token)` 返回的是被包装上下文给出的那个归一器实例(不是接口默认的 lambda)。 + +### 6.3 编译与测试命令 + +```bash +# 最强单一信号:全反应堆含测试源编译(禁止跳过测试编译) +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T1C test-compile + +# 跑本任务相关单测(必须禁用 build cache,否则 surefire 会被静默跳过、BUILD SUCCESS 是空的) +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml \ + -pl fe-connector/fe-connector-spi,fe-connector/fe-connector-iceberg,fe-connector/fe-connector-paimon \ + -Dmaven.build.cache.enabled=false test +``` + +注意 checkstyle 会扫测试源,新文件要按仓库现有风格写(许可头、import 顺序)。 + +### 6.4 端到端回归 + +本任务不改变任何现有运行时行为(补的两个方法今天无调用点,其余方法转发路径逐字等价),**不需要新增端到端用例**。既有的端到端把关点是 iceberg 的 Kerberos 回归套件——它验证 `executeAuthenticated` 的钉桩与单一认证方语义没被这次继承结构调整改坏;paimon 没有活的 Kerberos 套件,依靠上面的单测与 iceberg 套件同机理覆盖(这一点两个包装类的现有类注释已经写明)。 + +## 七、风险与回退 + +- **风险:继承结构调整改坏认证语义。** `executeAuthenticated` 是唯一带逻辑的方法,迁移时只把 `delegate` 字段访问换成 `delegate()`,其余一字不改;两个连接器现有的四条认证/钉桩断言全部保留,任何行为偏移都会被它们抓到。 +- **风险:类加载器层面的问题。** 基类放在 `org.apache.doris.connector.spi`,落在 `ConnectorPluginManager.java:65` 声明的 parent-first 前缀内(`org.apache.doris.connector.`),与 `ConnectorContext` 本身同一份副本,插件里的子类继承它不产生第二份类。这与两个包装类今天实现 `ConnectorContext` 的加载路径完全一致。 +- **风险:给公共模块增加了一个公共类。** 这个类是「引擎实现、连接器消费」这一侧的官方装饰基类,与该模块的定位一致,且它减少的是「以后每次改接口要动的地方」,方向与本条工作线的目标(新增连接器不必改公共模块)一致。 +- **回退**:改动自包含在 1 个新增类 + 1 个新增测试 + 2 个插件文件里,直接 revert 这一个提交即可,无数据格式、无持久化、无有线格式牵连。 + +## 八、相关背景 + +- 调研报告 `../audit-report.md`: + - **附录 D.2** —— 引擎侧接口默认值全是静默降级、两个包装类漏转发:本任务的直接来源,含三套政策的对比; + - 第 3.2 节「五个结构性问题」总表里编号为六的那一行 —— 同一条问题的一句话版; + - **附录 D.3** —— 会话接口同病、有默认值会静默关掉性能优化:同一类问题在 `ConnectorSession` 上的表现(`getStatementScope` 默认不记忆会静默关掉按语句去重)。本任务**不**处理它,但下一位实施者应知道它是同一机理的另一处。 +- 同一任务空间:任务 07(把两个公共模块的设计规则写下来,包括抽象/默认的政策)、任务 14(删除推模型失效接口,同样要改这两个包装类,**排在本任务之后**)。 +- 项目记忆:`catalog-spi-plugin-tccl-classloader-gotcha`(四个已修的类加载器分裂位置,解释了为什么漏一次钉桩是真事故)、`static-gate-only-for-existence-not-language-semantics`(为什么这里用单测而不是 shell 门禁)、`doris-build-verify-gotchas`(maven 绝对路径 `-f`、后台任务退出码的读法)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/07-write-down-the-design-rules.md b/plan-doc/connector-public-interface-cleanup/tasks/07-write-down-the-design-rules.md new file mode 100644 index 00000000000000..4038ad3106fdf9 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/07-write-down-the-design-rules.md @@ -0,0 +1,359 @@ +# 07. 把公共模块的设计规则写下来(两个模块各一份包级说明) + +> **优先级**:第二优先级(零风险,建议第一个合入) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe-connector-api`、`fe-connector-spi`(只动注释与 pom 的 ``,零 Java 逻辑改动) +> **预计改动规模**:新增 1 个文件、改写 1 个文件、修 2 处 pom 描述;约 200~260 行注释文本。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +--- + +## 一、一句话说明这个任务要解决什么 + +两个公共模块今天没有任何地方写下「一项能力该声明在哪里、异常该怎么抛、thrift 允许出现在哪、什么该放 +`fe-connector-api` 什么该放 `fe-connector-spi`、`getMetadata` 返回的对象活多久、哪些方法会在后台线程上被调用」, +所以新增一个连接器的人只能靠读既有连接器的实现去猜规则;这个任务就是把这些规则写成两份包级说明文档 +(`package-info.java`),并顺手把两处已经与代码不符的模块描述改对。 + +它不改任何运行时行为,但**后面每一批整治的判据都从这份规则来**(第 10、17 号任务在依赖表里直接依赖它), +所以应最先合入。 + +--- + +## 二、背景:现在的代码是怎么写的 + +**(a)`fe-connector-api` 完全没有包级文档。** 实测: + +``` +find fe/fe-connector/fe-connector-api -name package-info.java # 零命中 +find fe/fe-connector/fe-connector-api/src/main -name '*.java' | wc -l # 95 +``` + +95 个源文件、9 个子包(`api`、`api/ddl`、`api/event`、`api/handle`、`api/mvcc`、`api/procedure`、 +`api/pushdown`、`api/scan`、`api/write`),没有任何一份文档说明这些包的分工与设计约束。整个 +`fe-connector` 目录下只有两份包级文档:`fe-connector-cache` 一份、`fe-connector-spi` 一份。 + +**(b)`fe-connector-spi` 那份包级文档只说了一句话,而且不完整。** +`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/package-info.java:18-28` 全文只讲: +本包定义连接器必须履行的 SPI 契约,主入口是 `ConnectorProvider`,连接器实现「应当依赖本模块 +(`fe-connector-spi`)并在 `META-INF/services` 里注册」。它既没说连接器同时也要用到 +`fe-connector-api`(实际上 `fe-connector-spi/pom.xml:43-47` 声明了对 `fe-connector-api` 的依赖,所以是传递带进来的, +但读文档的人看不出来),也没有任何一句说明「什么该放 api、什么该放 spi」。 + +**(c)能力声明今天有三种形态并存,规则没写下来。** 三层形态本身在代码里是存在的: + +- `Connector` 上的取得器,缺席返回 `null`:`Connector.java:66`(`getScanPlanProvider`)、`:93` + (`getWritePlanProvider`)、`:192`(`getProcedureOps`); +- 各 provider 自己的开关:例如 `ConnectorWritePlanProvider` 上的 `supportsWriteBranch()` / + `requiresParallelWrite()` / `requiresFullSchemaWriteOrder()` / `requiresPartitionLocalSort()` / + `requiresPartitionHashWrite()` / `requiresMaterializeStaticPartitionValues()`; +- 能力枚举 `ConnectorCapability`(`ConnectorCapability.java:31-182`,共 13 个常量)。 + +但**哪一层放什么,只有 `ConnectorCapability` 的类文档写了一条边界**(`:20-29`:写操作与 sink 特性不进枚举、 +放 provider)。这条边界看上去有一处例外:建表期的 ORDER BY 子句门 `SUPPORTS_SORT_ORDER` 在枚举里 +(`:165`),写路径的行序 `requiresFullSchemaWriteOrder` 在 provider 上,两者都能被笼统地归为「写相关能力」。 +**但这不是疏漏,代码里已经写明了区分理由**:`SUPPORTS_SORT_ORDER` 的 javadoc(`ConnectorCapability.java:153-163`) +明确写了它是「建表 DDL 子句门」(`CREATE TABLE ... ORDER BY` 这个子句是否被接受,引擎在静态规划期就要判定, +此时还拿不到写计划提供者),并显式声明它**不同于**运行期 sink 特性 +`ConnectorWritePlanProvider.requiresFullSchemaWriteOrder()`(管的是写路径上行怎么排序)。 +所以规则文档要做的是**把这条既有理由收录成判据**(「DDL 子句门进枚举、运行期 sink 特性进 provider」), +**不要**把它当成需要被纠正的不一致去搬动 `SUPPORTS_SORT_ORDER`。 + +**(d)按表能力走的是逗号分隔字符串,且只对 13 个能力中的 5 个生效。** +`ConnectorTableSchema.java:98` 定义键 `__internal.connector.per-table-capabilities`;引擎唯一读它的地方是 +`PluginDrivenExternalTable.java:302` 的私有方法 `hasScanCapability`,调用方 5 处 +(`:239`、`:251`、`:264`、`:276`、`:289`)。其余 8 个能力只读连接器级集合。 + +**(e)异常契约完全缺失。** `DorisConnectorException.java` 全文只有一个 `RuntimeException` 子类和两个构造器, +类文档一句「Base runtime exception for all connector-related errors.」,没有任何分类、没有任何该抛/不该抛的判据。 +连接器实际混用多个异常族(在 `fe/fe-connector` 下 grep `throw new`:`DorisConnectorException` 395 处、 +`UnsupportedOperationException` 330 处、`IllegalArgumentException` 111 处、`RuntimeException` 102 处、 +`IllegalStateException` 30 处),而引擎侧只翻译 `DorisConnectorException` 这一族 +(`fe-core` 里 `catch (DorisConnectorException` 共 32 处,典型如 +`PluginDrivenExternalCatalog.java:816` 把它包成 `DdlException`)。其余族一路冒泡成 FE 内部错误。 + +**(f)thrift 在两个模块上是两套相反的规则。** `fe-connector-api` 直接用 thrift 生成类,完整清单(实测): + +| 位置 | thrift 类型 | +|---|---| +| `api/scan/ConnectorScanPlanProvider.java:24-26` | `TFileCompressType`、`TFileScanRangeParams`、`TTableFormatFileDesc` | +| `api/scan/ConnectorScanRange.java:20-21` | `TFileRangeDesc`、`TTableFormatFileDesc` | +| `api/handle/ConnectorWriteHandle.java:21` | `TSortInfo` | +| `api/write/ConnectorSinkPlan.java:20` | `TDataSink` | +| `api/ConnectorTableOps.java:464` | `org.apache.doris.thrift.TTableDescriptor`(**内联全限定名**,不是 import) | + +(另有一处只出现在注释里:`api/write/ConnectorWritePlanProvider.java:33` 的 javadoc 提到 `TDataSink`, +不构成依赖。`fe-thrift` 在 `fe-connector-api/pom.xml` 里是 `provided` 作用域、不传递。) +而 `fe-connector-spi` 刻意绕开 thrift:`ConnectorContext.java:255` 的 `getBackendFileType` 返回**枚举名字符串**、 +`:291` 的 `getBrokerAddresses` 返回中立的 `ConnectorBrokerAddress`、`:337` 的 +`testBackendStorageConnectivity(int storageBackendTypeValue, ...)` 把一个 thrift 枚举的**整数值**当参数传。 + +**(g)生命周期与线程模型一字未写。** +`Connector.java:45` 的 `getMetadata` 全部文档就是「Returns the metadata interface for the given session.」。 +实际契约在引擎侧:`PluginDrivenMetadata.java:28-42` 明确写了「一条语句每个 catalog 恰好用一个 +`ConnectorMetadata` 实例,语句结束时确定性关闭」,而且这是 fe-core 里唯一允许直接调 `getMetadata` 的地方。 +`ConnectorMetadata.java:233` 的 `close()` 默认空实现,无任何幂等/线程要求。 +统计接口 `ConnectorStatisticsOps.java:30` 的类文档只有一句「Operations for retrieving table-level statistics +from a connector.」——**没写它会在引擎的后台统计线程上被调用,也没写引擎不会钉线程上下文类加载器**。 +证据:`PluginDrivenExternalTable.java:1026` 的 `getColumnStatistic` 与 `:1061` 的 `getChunkSizes` 全程不钉 +类加载器,`:1057` 的注释甚至明写「No TCCL pin here; the hive `listFileSizes` impl pins internally」;对应的连接器侧 +`HiveConnectorMetadata.java:939-954` 自己动手 `setContextClassLoader` 兜的正是这件事。 + +**(h)两处模块描述已经与代码不符。** +`fe-connector-spi/pom.xml:38` 写自己「包含 `ConnectorProvider`、`ConnectorContext` 和 `ConnectorTypeMapper`」—— +`ConnectorTypeMapper` 这个类**全仓不存在**(Java 源里零命中;命中只有这行描述本身、调研报告引用它的那一行, +以及构建生成物 `fe/fe-connector/fe-connector-spi/.flattened-pom.xml:29` 里这行描述的副本—— +第六节自检那条带 `--include='*.xml'` 的 grep 会命中生成物,不要误判成「没删干净」)。 +`fe-connector-api/pom.xml:35-38` 自称「Consumer-facing API」(面向消费方的 API),而它实际装的是**连接器要实现**的 +95 个接口与值对象。 + +--- + +## 三、为什么这是个问题 + +**(1)没有规则,新增连接器的第一个动作就是猜。** 想声明一项能力,作者要在 `Connector`(取得器返回 null)、 +provider 的 `supportsXxx()`、`ConnectorCapability` 枚举、按表能力字符串这四种形态里选一个,而四种形态并存的 +理由没有任何地方写。选错了不会有编译错误,也不会有测试失败——只会「实现了但没生效」,然后花半天排查。 + +**(2)文档写在错的地方比没写更贵。** 现在唯一写下来的两句都不准:`fe-connector-spi` 的包文档说自己是 +「连接器实现必须履行的契约」(实际上连接器要实现的东西 95% 在 `fe-connector-api`),`fe-connector-api` 的 pom +说自己是「面向消费方」(实际相反)。照这两句去理解模块分工,会得到完全反的结论。 + +**(3)异常没有分类,用户看到的报错就没有分层。** 连接器抛 `IllegalArgumentException` 时引擎不翻译, +用户拿到的是一条内部异常;抛 `DorisConnectorException` 才会被翻译成 `DdlException` 这类可读错误。 +今天这个区别没写在任何契约里,等于让「用户能不能看懂报错」取决于连接器作者随手选了哪个异常类。 + +**(4)线程与类加载器这条契约「踩了就炸」,而它一字未提。** 本项目已知的高危坑就是跨插件类加载器的按名反射: +统计方法跑在引擎后台线程上、引擎不钉线程上下文类加载器,连接器如果不自己钉,捆绑库按名反射会撞上 fe-core 里的 +重复副本,表现为 `ClassCastException` / `NoClassDefFoundError`,而且是偶发的(只在后台 ANALYZE 触发时炸)。 +hive 连接器是自己摸出来后补的救;下一个连接器作者没有任何线索知道要补。 + +**(5)后面每一批整治都需要判据。** 第 10 号(拆 `ConnectorTableOps`)与第 17 号(按表能力类型化 + 删镜像方法) +都要回答「这个开关该落在哪一层」。规则不先落地,那两批就是逐项拍脑袋。 + +--- + +## 四、用一个最小例子说明 + +假设我要新增一个连接器 X,只想做一件很小的事:**告诉引擎「我这个连接器支持写入分支(write branch)」**。 + +| 我想做什么 | 今天我实际会遇到什么 | 规则写下来之后应该是什么 | +|---|---|---| +| 找一份文档看规则 | `fe-connector-api` 没有包级文档;`fe-connector-spi` 的包级文档只说「注册 `ConnectorProvider`」 | `fe-connector-api/package-info.java` 第一段就说明能力声明分三层,以及每层的判据 | +| 挑一个地方声明 | 看到 `Connector.supportsWriteBranch()`(`Connector.java:132`)和 `ConnectorWritePlanProvider.supportsWriteBranch()` 两个同名方法,不知道该覆写哪个 | 规则明确:**覆写 provider 上的那个**;`Connector` 上的同名方法是引擎用的空安全读取口,连接器不得覆写 | +| 覆写 `Connector.supportsWriteBranch()` | 能编译、能运行,但如果同时没改 provider,两个答案就分叉;**没有任何测试会失败** | 规则把「唯一真源在 provider」写成契约,第 17 号任务再把这些方法改成语言层面无法覆写的形式 | + +再举第二个更小的:我的连接器发现远端表不存在,该抛什么异常? + +``` +throw new IllegalArgumentException("table not found: " + name); // 今天:引擎不翻译,用户看到内部异常栈 +throw new DorisConnectorException("table not found: " + name); // 今天:引擎翻译成 DdlException,用户看到可读错误 +``` + +两行都能编译、都能跑,差别只在用户看到的报错长什么样,而**没有任何文档告诉作者该选哪一行**。 + +--- + +## 五、解决方案 + +### 5.1 目标状态 + +新增 `fe-connector-api` 的包级说明,改写 `fe-connector-spi` 的包级说明,两份互相引用。 +**文档正文用英文**(与仓库既有 javadoc 一致,`fe-connector-cache/package-info.java` 是可照抄的格式模板: +ASF 头 + 一段 `

      ` 段落,不写任何 import)。下面用中文写清每条规则要表达什么。 + +**规则一:能力声明分三层,且只有三层。** + +1. **「整块子系统有没有」** → `Connector` 上的 provider / ops 取得器,缺席返回 `null` + (`getScanPlanProvider` / `getWritePlanProvider` / `getProcedureOps` / `getEventSource`)。 + 保持 `null` 而不是改 `Optional`:引擎已按 `null` 判定,换掉是纯变更噪音。 +2. **「某块子系统内部的开关」** → 该 provider 自己的 `supportsXxx()` / `requiresXxx()`,一律默认 `false` + (opt-in),引擎必须先拿到 provider 再问。**唯一真源在 provider**。 + `Connector` 上现存的 11 个同名方法(`Connector.java:115/126/132/138/144/150/156/162/168/177/183`) + 是引擎侧的空安全读取口(方法体一律「取 provider,为空返 false,否则转发」), + **连接器不得覆写它们**——今天 0 个连接器覆写(已核实:三个连接器只在各自的 + `*WritePlanProvider` 上覆写),规则要把这件事从「运气」变成「契约」。 +3. **「引擎拿不到 provider 也必须问的静态规划开关」** → `ConnectorCapability` 枚举,且一律按 + 「连接器级 ∪ 按表级」加法解析。凡是与某个 provider 一一对应的开关不得进枚举。 + 现成的判据范例(照抄 `ConnectorCapability.java:153-163` 已写明的区分,不要改设计): + 建表 DDL 子句门 `SUPPORTS_SORT_ORDER` 留在枚举里,因为引擎要在静态规划期判定 + `CREATE TABLE ... ORDER BY` 这个子句是否被接受,此时还拿不到写计划提供者; + 而运行期的行序 sink 特性 `requiresFullSchemaWriteOrder()` 在 provider 上。 + 两者名字都沾「写」,但一个管「DDL 子句收不收」、一个管「写路径上行怎么排」,分层是对的。 +4. **「值缺失」用 `Optional`,「整块子系统缺席」用 `null`** —— 这两者的区分写进规则,避免被当成两套竞争机制。 + +同时写清现状偏差:按表细化今天只对 5 个能力生效(`hasScanCapability` 的 5 个调用方), +接口承诺的是 13 个;第 17 号任务负责补齐,在那之前**不要假设自己新加的枚举常量能按表细化**。 + +**规则二:异常分四类,并给出 fail loud 与静默降级的判据。** + +- 连接器一律抛 `DorisConnectorException` 或其子类(这是引擎唯一会翻译的一族,32 处 `catch`)。 +- 四类:**用户错误**(SQL 或参数不合法 / 对象不存在)、**配置错误**(catalog 属性缺失或矛盾)、 + **远端不可用**(元数据服务或存储超时、拒绝、鉴权失败)、**内部错误**(连接器自身的不变量被破坏)。 +- 判据:**凡是用户显式发起、结果会被用户直接看到的操作,一律 fail loud**(DDL、写入、 + `ANALYZE ... WITH SAMPLE` 这类显式统计采样);**凡是引擎为优化而做的尽力而为探测,失败必须静默降级** + (返回空 / 返回默认值,并记日志),因为它们不该让查询失败。 +- 用这条判据顺带把两处矛盾定性(具体修文档是第 8 号任务的事,本任务只写规则): + `ConnectorStatisticsOps.java:96` 的 `listFileSizes` 文档写「出错必须返回空、不得抛异常」, + 唯一实现 `HiveConnectorMetadata.java:931-955` 故意抛出且有单测锁死——按判据**实现是对的、文档是错的**, + 显式采样吞异常会让采样因子静默塌成 1.0、产出错误统计。 + `ConnectorMetadata.java:141-143` 的 `resolveTimeTravel` 文档写「不支持的规格返回空」, + iceberg 与 hudi 对不支持的规格抛 `DorisConnectorException`(`IcebergConnectorMetadata.java:2038` 附近、 + `HudiConnectorMetadata.java:488` 附近),paimon 一律返回空——按判据抛出是对的(用户显式写了 + `FOR VERSION AS OF`,静默当作没写会给出错的结果集),应把文档改成据实。 + +**规则三:thrift 只允许出现在面向 BE 的协议边界上。** + +- 承认结构性事实:BE 是 C++、没有插件机制,面向 BE 的负载必须走 thrift。 +- 把 §二(f)的完整清单原样写进文档(5 个位置),并明确:**不再新增以 thrift 类型为入参的方法**; + 新增的返回值若必须携带 BE 负载,只允许复用清单里已有的类型。 +- 把 `fe-connector-spi` 侧那三处「为了 thrift-free 而做的字符串/整数化」的现状与理由记下来, + 并注明这条 thrift-free 承诺**并未真正兑现**(`spi` 依赖 `api`,`api` 依赖 `fe-thrift`), + 所以它是一处局部约定而不是模块级不变量。是否统一留待后续,不在本任务范围。 + +**规则四:`api` 与 `spi` 的划分以「谁实现」为准,并写明现状偏差。** + +- 规则:**连接器实现、引擎消费** 的类型放 `fe-connector-api`;**引擎实现、连接器消费** 的类型 + 以及服务发现入口放 `fe-connector-spi`。依赖方向 `spi → api` 单向(已核实:`api` 里 + `org.apache.doris.connector.spi` 零命中)。 +- 写明偏差:`ConnectorSession`、`ConnectorHttpSecurityHook`、`ConnectorValidationContext` 三个「引擎实现」的类型 + 今天在 `fe-connector-api` 里;这属于已知偏差,**不在本任务里搬**(搬动会波及全部连接器的 import)。 +- 写明**两个模块名整体是反着的**:按业界惯例(也是 Trino 的用法)「连接器要实现的东西」叫 SPI、 + 「连接器要消费的引擎服务」叫 API,而这里 `fe-connector-api`(95 个文件)装的是连接器要实现的, + `fe-connector-spi`(5 个文件)装的是连接器要消费的。**明确不改模块名**(改名波及所有连接器的 pom + 与 fe-core 依赖,收益只是命名),只把这件事写清楚,让读代码的人第一眼就知道名字别当真。 +- 顺手修两处 pom 描述:删掉 `fe-connector-spi/pom.xml:38` 里全仓不存在的 `ConnectorTypeMapper`; + 把 `fe-connector-api/pom.xml:35-38` 的「Consumer-facing API」改成据实(连接器实现、引擎消费)。 + +**规则五:生命周期与线程模型。** + +- `Connector` 每个 catalog 一个实例(`Connector.java:36-41` 的类文档已写,保持)。 +- `getMetadata` 返回的实例:**每条语句每个 catalog 恰好一个**,由引擎在语句结束时关闭; + `close()` **必须幂等**;**单个实例不得跨线程共享**。文档里指向引擎侧的唯一入口 + `PluginDrivenMetadata`(它已经把这个契约写清了,`PluginDrivenMetadata.java:28-42`), + 并在 `ConnectorMetadata.close()`(`:233`)上交叉写一句。 +- **统计接口(`ConnectorStatisticsOps` 全部方法)会在引擎的后台统计线程上被调用, + 且引擎不会钉线程上下文类加载器**:连接器若要按名反射加载捆绑库中的类,必须自己在方法内 + 把线程上下文类加载器钉到插件类加载器上(现成范式:`HiveConnectorMetadata.java:939-954`)。 + 这条是本任务里唯一「不写就会炸」的契约,必须写进 `ConnectorStatisticsOps` 的类文档, + 并在包级说明里点一句。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/package-info.java` | **新建**。ASF 头 + 五条规则的英文成文;每条规则附「现状偏差」一句 | +| `fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/package-info.java` | **改写**(现文 `:18-28`)。补规则四(以「谁实现」为准 + 模块名倒置 + 不改名)、补一句「连接器同时用到 `fe-connector-api`,规则全文见那份包级说明」,删掉「本包定义连接器必须履行的契约」这种与事实相反的表述 | +| `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatisticsOps.java` | 类文档(`:27-30`)加一段:后台统计线程调用 + 引擎不钉线程上下文类加载器 + 连接器自钉的要求 | +| `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java` | `close()`(`:233`)上加一句幂等 + 每语句一实例 + 不跨线程;类文档(`:36-43`)指向 `PluginDrivenMetadata` 的契约 | +| `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java` | `getMetadata`(`:45`)文档补每语句一实例;11 个空安全读取口(`:115` 起)各加一句「连接器不得覆写,真源在 provider」 | +| `fe/fe-connector/fe-connector-spi/pom.xml` | `:38` 删掉 `ConnectorTypeMapper` | +| `fe/fe-connector/fe-connector-api/pom.xml` | `:35-38` 把「Consumer-facing API」改成据实描述 | + +写作约束(照抄 `fe-connector-cache/package-info.java` 的形式):ASF 许可头必需(checkstyle 的 `Header` 模块 +按 `checkstyle-apache-header.txt` 校验);单行不超过 120 字符(`LineLength`);文件末尾 LF 换行 +(`NewlineAtEndOfFile`);**不要在 `package-info.java` 里写 import**——本仓库 `UnusedImports` 模块没有开 +`processJavadoc`(`fe/check/checkstyle/checkstyle.xml:167`),只在 javadoc 里用到的 import 会被判为未使用, +跨包引用一律写全限定名或用 `{@code ...}`。 + +### 5.3 明确不要顺手做的事 + +| 不要做 | 为什么 | +|---|---| +| 不要改模块名(`fe-connector-api` ↔ `fe-connector-spi`) | 波及所有连接器的 pom 与 fe-core 依赖,收益只是命名对齐惯例;本任务只把倒置这件事写清楚 | +| 不要把 `ConnectorSession` / `ConnectorHttpSecurityHook` / `ConnectorValidationContext` 搬到 `spi` | 会改动全部连接器的 import,属于独立的机械改动;本任务只记录偏差 | +| 不要删 `Connector` 上那 11 个空安全读取口 | 那是第 17 号任务的内容(要连带改引擎调用点);本任务只写「连接器不得覆写」的契约 | +| 不要动按表能力的字符串通道 | 第 17 号任务负责类型化;本任务只写下目标规则与「今天只有 5 个能力生效」的偏差 | +| 不要顺手把 `resolveTimeTravel` / `listFileSizes` 的文档改了 | 那是第 8 号任务(成批修陈旧文档)。本任务只给判据,避免两个提交改同一段注释 | +| 不要给 `api` 的 8 个子包各补一份 `package-info.java` | 规则集中在一处才有人读;子包各一份会散掉,且首次就要维护 9 份 | +| 不要写 shell/正则静态门禁去校验「thrift 只出现在清单里」 | 本仓库已有结论:这类门禁只适合存在性与前缀类不变量,判断语言语义的门禁误报比漏报更毒(授权缓存门禁已因此被删)。thrift 清单靠评审 + 这份文档约束 | +| 不要往 `fe-core` 新增任何东西 | 当前阶段 `fe-core` 只出不进 | + +--- + +## 六、怎么验证 + +**(1)编译门禁(最强单一信号)。** `package-info.java` 参与编译,注释里的 `{@link}` 写错不会挂编译, +所以编译只证明没有语法/头部问题: + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T1C test-compile +``` + +必须是全反应堆、**含测试源**,禁用任何跳过测试编译的参数。判据是输出里的 `BUILD SUCCESS`。 + +**(2)checkstyle 必须过**(本任务真正会被卡的门): + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -pl fe-connector/fe-connector-api,fe-connector/fe-connector-spi checkstyle:check +``` + +关注四项:ASF 头、行长 120、末尾 LF、`package-info.java` 里没有 import。 + +**(3)javadoc 引用可解析(可选但建议)。** 对两个模块跑一次 javadoc 生成,确认没有 +「reference not found」告警——写错的 `{@link}` 只会在这里暴露。 + +**(4)事实一致性自检(这是本任务最重要的验证,因为写错的规则比没规则更毒)。** +文档里出现的每个数字与清单,动手时用命令逐条复核一遍,不要照抄本文: + +``` +# thrift 清单是否仍是 4 个 import 文件 + 1 处内联全限定名 +grep -rn 'org\.apache\.doris\.thrift\.' fe/fe-connector/fe-connector-api/src/main --include='*.java' +# Connector 上的空安全读取口是否仍是 11 个,且连接器仍零覆写 +grep -n 'default boolean supports\|default boolean requires\|default Set' \ + fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java +grep -rn 'public boolean supportsWriteBranch\|public boolean requires' fe/fe-connector/*/src/main --include='*.java' +# 按表能力仍只服务 5 个调用方 +grep -n 'hasScanCapability' fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java +# ConnectorTypeMapper 是否仍不存在(改完后应只剩生成物 .flattened-pom.xml 里的旧副本,那不算残留) +grep -rn 'ConnectorTypeMapper' fe/ --include='*.java' --include='*.xml' +``` + +**(5)不需要的验证。** 零行为改动:不需要新增单元测试、不需要变异验证、不需要端到端回归。 +(唯一的边界情况是 pom `` 改动,它不参与任何逻辑。) + +--- + +## 七、风险与回退 + +- **主要风险不是构建挂,而是把规则写错。** 一份写错的规则会让后续每个连接器作者照错的规则实现, + 比没有规则更贵。缓解办法有三条:每条规则必须能在 HEAD 上指出对应代码(本文 §二 的每一条都可复核); + 每条规则后面附一句「现状偏差」,不把目标状态写成既成事实;把「这条规则由第几号任务兑现」写清, + 读者不会误以为今天就已经生效。 +- **第二个风险是与第 8 号任务撞注释。** 第 8 号要成批修陈旧的接口文档,两个任务都会碰 + `ConnectorStatisticsOps` / `ConnectorMetadata` 的 javadoc。缓解:本任务只加「线程与生命周期」段落, + **不修 `listFileSizes` / `resolveTimeTravel` 的文案**,文案留给第 8 号;若两者并行,先合本任务。 +- **回退**:单个提交 revert 即可,零运行时影响(改动全是注释与 pom 描述)。 + +--- + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` + - 第五节(主题二):能力声明的多条并行通道,5.3 的第一步就是本任务 + - 第九节(主题六):两套相反的 thrift 规则,含建议写进包级说明的清单 + - 第十节 10.3 / 10.4:异常契约缺失、生命周期与线程模型没写进契约 + - 第十三节(主题十):两个公共模块的边界说不清 + 模块名倒置 + `ConnectorTypeMapper` 陈旧描述 + - 第十四节:被推翻或收窄的说法(动手前值得看一眼,避免把好设计写成缺陷) +- 同目录 `README.md` 第二节任务清单:第 10、17 号任务在依赖表里依赖本任务 +- 格式模板:`fe/fe-connector/fe-connector-cache/src/main/java/org/apache/doris/connector/cache/package-info.java` +- 引擎侧已成文的生命周期契约(写规则时直接引用,不要重写): + `fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenMetadata.java:28-42` + +--- + +## 九、施工后订正(2026-07-25 落地时实测,以本节为准) + +本节记录动手前复核发现的、与 §二 不符的事实。**§二 里被订正的说法不要再引用。** + +1. **异常族:引擎翻译的是两族,不是一族。** 元数据 / DDL / DML / 扫描规划路径认 `DorisConnectorException`(32 处 catch);**属性校验路径只认 `IllegalArgumentException`**——`PluginDrivenExternalCatalog.checkProperties` 捕获它并把 message 重抛为 `DdlException`,那里它是唯一会被解包的类型,5 个连接器共 13 处抛点依赖它,`HiveConnectorProvider` 的注释已写明「这是唯一…」。规则文档已按「分路径」写。**若按 §二 那句「只翻译一族」去统一异常,会把建目录的报错退化成内部异常。** +2. **异常族计数的口径错了。** §二(e) 的五个数字是 main+test 混计。生产(`src/main`)真实数字:`DorisConnectorException` 395、`UnsupportedOperationException` **50**(不是 330——那 330 里 280 处是测试替身的未实现桩)、`IllegalArgumentException` 109、`RuntimeException` 90、`IllegalStateException` 23。也就是说生产代码已经约 74% 集中在 `DorisConnectorException` 上,「混用严重」这个论据要按生产口径收窄。 +3. **异常族已经有一个子类,且是承重的。** `HiveDirectoryListingException extends DorisConnectorException`,作用是让扫描路径只 catch「可跳过的列目录失败」,而普通 `DorisConnectorException` 仍然失败整个查询。规则文档把它作为**已获批准的扩展范式**引用,而不是另发明分类。 +4. **缺席取得器是 4 个不是 3 个**:漏了 `getEventSource`(§5.1 规则一里本来就列了 4 个,是 §二(c) 少写一个)。 +5. **「能力开关一律默认 false」在今天不成立**:`ConnectorPushdownOps.supportsCastPredicatePushdown` 默认 `true`;且 `supportsXxx` 形态还出现在 `ConnectorScanPlanProvider`(3 个)与三个非 provider 接口上。规则文档写成「目标 + 唯一既有例外」。 +6. **统计方法的线程模型比 §二(g) 写的更宽**:不是「后台统计线程」单数,而是三个不同的守护线程池(列统计缓存加载 `STATS_FETCH`、采样 ANALYZE 的分析作业执行器、外部行数刷新执行器),且**没有一条路径钉类加载器**;带快照的 `getTableStatistics` 重载只在查询线程上。文档按「多个后台池 + 把所有方法都当可后台调用」写。 +7. **`fe-connector-api/pom.xml` 的 `` 跨 :35-43**(不是 :35-38),后面还有一段关于 fe-thrift provided 作用域的说明,改写时要保留。 +8. **`package-info.java` 里不能写 import 的理由是错的**:`UnusedImports` 的 `processJavadoc` 在 checkstyle 9.3 下默认**开启**,只在 javadoc 里用到的 import 不会被判未使用。实际约束是另一条:**全仓没有任何 javadoc 校验绑定**(8 个 pom 里的 javadoc 插件全是 `skip=true`),所以 `{@link}` 写错既不会挂构建也不会告警,只能靠人核。另外连接器路径的 javadoc 内容类检查(`JavadocMethod` / `MissingJavadocType` 等)被 `suppressions.xml` 整体豁免。 + +**实际落地与 §5.2 的两处偏差**: +- `Connector` 上 11 个空安全读取口没有逐个加「不得覆写」注释(11 行近乎相同的注释是噪音),改为在**类文档里写一次**、覆盖全部 `supportsXxx` / `requiresXxx` / `supportedWriteOperations`,并说明反面(返回 `null` 的取得器才是声明点)。 +- `ConnectorStatisticsOps` / `ConnectorMetadata` 的改动只加生命周期与线程段落,未碰 08 号负责的文案,按 §七 的约定执行。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/08-fix-stale-interface-docs.md b/plan-doc/connector-public-interface-cleanup/tasks/08-fix-stale-interface-docs.md new file mode 100644 index 00000000000000..f0919bda290453 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/08-fix-stale-interface-docs.md @@ -0,0 +1,164 @@ +# 08. 修正与实现矛盾的接口文档(一批) + +> **优先级**:第二优先级(零风险,可与「把公共接口的书写规则写下来」那个任务同批提交) | **风险**:低 | **前置依赖**:无。但与「删掉没有调用方的接口面」那个任务有三处重叠,处理办法见 5.2 表格与 5.3 +> **影响模块**:`fe-connector-api`(全部是 javadoc 文字改动);可选一处 `fe-connector-hive` 的测试补充 +> **预计改动规模**:9 个文件,60~90 行注释;除「待拍板的一条默认值」外零行为改动 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +公共接口上有九处 javadoc 描述的引擎行为,和引擎里真实发生的事情不一致(有的说「引擎会读这个值」而引擎从不读,有的说「引擎会帮你兜底」而兜底代码已经被删掉),另外三处 javadoc 里还留着只有我们内部看得懂的设计代号;这个任务把这些文字逐条改成实测事实,并把其中一条真实的安全隐患单独提出来请你拍板。 + +## 二、背景:现在的代码是怎么写的 + +九条逐一列出(每条都在 `7ff51a106f0` 上核实过)。 + +**(1)`ConnectorTableOps.listPartitionValues`**(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java:493-498`,签名紧随其后的 `:499-501`)文档写「Used by the `partition_values()` TVF and by column-distinct-value optimizations」。实测 `fe-core/src/main` 下这个方法名零命中;`partition_values()` 表函数的真实路径是 `MetadataGenerator.java:2035` → `PluginDrivenExternalTable.getNameToPartitionValues`(`:882`)→ `metadata.listPartitions`(`:899`)。 + +**(2)`ConnectorScanRangeType`**(`.../api/scan/ConnectorScanRangeType.java:20-33`)文档写「Each type maps to a specific Thrift scan range variant」,`ConnectorScanRange.java:33-35` 与 `ConnectorScanPlanProvider.java:44-51` 也重复了同样的话。实测 `fe-core/src/main` 从不调 `getRangeType()` 或 `getScanRangeType()`;分片一律被包成 `PluginDrivenSplit extends FileSplit`(`PluginDrivenSplit.java:35-47`),真正区分格式的是 `ConnectorScanRange.populateRangeParams`(`ConnectorScanRange.java:182`)的多态覆写。 + +**(3)`ConnectorEventSource.getCurrentEventId`**(`.../api/event/ConnectorEventSource.java:44-49`)文档写「Used by the master to cheaply decide whether there is anything new to pull before calling `pollOnce`」。实测唯一命中是 hms 的实现(`HmsEventSource.java:58`);引擎侧的元存储事件驱动只调 `pollOnce`(`MetastoreEventSyncDriver.java:164`)。 + +**(4)`ConnectorContractValidator`**(`.../api/ConnectorContractValidator.java:29-34`)文档写这些不变量「are enforced by the per-connector contract tests (which build each connector and call validate)」。实测只有四个连接器的测试在调:iceberg(`IcebergConnectorTest.java:368`)、es(`EsScanPlanProviderTest.java:332`)、maxcompute(`MaxComputeConnectorContractTest.java:66`)、jdbc(`JdbcDorisConnectorTest.java:187`)。而 `:57-62` 与 `:65-69` 那两条关于「按分区哈希写」的不变量,唯一声明该能力的连接器是 hive(`HiveWritePlanProvider.java:139`),hive 的测试里没有任何 `validate` 调用——真实连接器上没有正样本,只有 `fe-core` 里用假连接器构造的 `ConnectorContractValidatorTest` 覆盖。另外该类校验的是连接器级取得器(`connector.requiresPartitionHashWrite()`),而引擎写路径读的是按表重载(`Connector.java:167-171`,经 `PluginDrivenExternalTable.requirePartitionHashOnWrite`,`:378-390`)。 + +**(5)`ConnectorProcedureOps.getSupportedProcedures`**(`.../api/procedure/ConnectorProcedureOps.java:48-52`)文档写「used by the engine for routing, validation, and SHOW-style discovery」。实测:路由按表类型(`ExecuteActionFactory.java:57-59`)加执行模式(`ConnectorExecuteAction.java:142` 的 `getExecutionMode`);未知过程名由连接器在 `execute` 内部拒绝,引擎侧的 `isSupported` 恒返回 true 并在注释里写明了这一点(`ConnectorExecuteAction.java:227-231`);唯一读取点 `ExecuteActionFactory.getSupportedActions`(`:78-89`)自身零生产调用方,注释自陈是「no live caller today」的预留。 + +**(6)`ConnectorPartitionInfo.orderedPartitionValues`**(`.../api/ConnectorPartitionInfo.java:52-61`)文档写「Empty means "not supplied": fe-core then falls back to parsing partitionName itself (unchanged behavior)」。实测 `fe-core` 侧的名字解析兜底已经删除,`PluginDrivenMvccExternalTable.java:328-332` 写着「There is no name-parsing fallback anymore」,并用 `Preconditions.checkState(partitionValues.size() == types.size(), ...)` 做元数硬校验;这个抛出被调用方的 try/catch 接住(`:271-302`),后果是该分区被跳过、整表退化成「无分区」。 + +**(7)`ConnectorMvccSnapshot`**(`.../api/mvcc/ConnectorMvccSnapshot.java:25-32` 与 `:77`)类文档写「serialized into BE scan ranges so the read path sees a consistent version」,`getProperties()` 文档写「Connector-specific metadata propagated to BE」。实测这个类连 `Serializable` 都没实现(`:34`);引擎不把它放进任何分片,`properties` 的唯一消费者是连接器自己的 `applySnapshot`(paimon `PaimonConnectorMetadata.java:757-769`、iceberg `:2092`、hudi `:594/:645`),这条契约写在 `ConnectorMetadata.java:151-160`。 + +**(8)`ConnectorMetadata.getSyntheticScanPredicates`**(`.../api/ConnectorMetadata.java:169-185`)文档写「the engine NEVER discriminates by connector here; it applies whatever the connector returns」。前半句是真的,后半句不成立:反向转换器 `ConnectorExpressionToNereidsConverter` 只接受 `ConnectorAnd`、五种比较(EQ/LT/LE/GT/GE,`:100-114`)、能按名绑定到扫描输出列的列引用(`:124-135`)和 STRING 字面量(`:144-155`),其余一律抛 `AnalysisException`(类注释 `:53-59` 明确说这是有意的 fail loud)。 + +**(9)`ConnectorPushdownOps.supportsCastPredicatePushdown`**(`.../api/ConnectorPushdownOps.java:60-74`)文档写「When this returns false, the engine will strip any conjuncts containing CAST expressions from the filter before passing it to the connector」。实测这个剥离只发生在残余谓词那条路上(`PluginDrivenScanNode.buildRemainingFilter`,`:2053-2079`);`applyFilter` 那条路上引擎直接把全部 conjuncts 转换后交给连接器(`convertPredicate`,`:874-878` 调 `buildFilterConstraint`),不查这个能力位。而正向转换器遇到 CAST 是**直接把外壳拆掉、只推里面的子表达式**(`ExprToConnectorExpressionConverter.java:108-109`:`return convert(expr.getChild(0));`),连接器看到的是一个「看起来没有类型转换」的谓词,没有任何标记可供自查。今天覆写这个方法的是 paimon(恒 false)、maxcompute(恒 false)、jdbc(按会话开关);实现了 `applyFilter` 的三个连接器 hive、hudi、trino 全部继承默认值 `true`。 + +**(10)内部代号**:`Connector.java:217` 写着「Design S8: storage-property derivation is owned by the connector」;`handle/ConnectorTransaction.java:79` 与 `pushdown/ConnectorPredicate.java:24` 各写着一个「(O5-2)」。另有六处裸工单号 `#65329`(`ConnectorColumn.java:61/147/217/222`、`ConnectorType.java:81`、`ConnectorTableOps.java:326`)。 + +## 三、为什么这是个问题 + +公共接口的 javadoc 是新连接器作者唯一的行为契约来源——他不会去读 `fe-core`,读不了也不该读(连接器是独立打包、独立类加载器的插件)。文档说错引擎行为,代价有三种,且都已经在这九条里出现: + +1. **照文档写就是写错。** 第 6 条最典型:文档承诺「不填分区值,fe-core 会自己解析分区名」,照此实现的新连接器会得到「所有分区被跳过 → 表被当成无分区表 → 分区裁剪全丢、`EXPLAIN` 显示 `partition=0/0`」。这是用户能观察到的性能塌陷,而且不报错、不告警,只在日志里留一条 warn。 +2. **排查成本白烧。** 第 2、3、5 条都是「文档说引擎会读,引擎不读」。作者按文档设置了值、发现不生效,就会去翻引擎,翻不到东西,最后只能靠读全仓才敢下结论。 +3. **文档把一个真实隐患描述成了已经解决的问题。** 第 9 条最危险:文档让人相信「返回 false 就安全了」,而实际 `applyFilter` 路径完全不看这个位。hive 的 `applyFilter` 会用等值谓词直接裁掉元存储分区(`HiveConnectorMetadata.java:1085-1116`),拿到的又是被拆掉类型转换外壳的谓词——一旦源侧的比较语义与 Doris 的强转语义不一致,就是**多裁分区、少返回行,而且 BE 复算补不回来**(分区已经不在扫描范围里)。这一条的行为后果是代码路径推断,未跑端到端验证,但机制是确证的。 + +内部代号那几处是另一类问题:这些文件最终会随 PR 进上游社区,`Design S8` / `O5-2` 对仓库外的读者是纯噪音,而且它替换掉了本该写在那里的技术理由。 + +## 四、用一个最小例子说明 + +拿第 9 条(隐式类型转换那条)举例。假设一张 hive 分区表,分区列 `dt` 是 `STRING` 类型: + +```sql +CREATE TABLE hive_catalog.db.t (id INT, v STRING) PARTITIONED BY (dt STRING); +SELECT * FROM hive_catalog.db.t WHERE dt = 20240101; -- 注意右边是数字,不是字符串 +``` + +| 环节 | 文档让人以为会发生 | 实际发生 | 应该被文档写成 | +|---|---|---|---| +| Doris 分析谓词 | —— | 变成 `CAST(dt AS INT) = 20240101` | 同 | +| 引擎要不要剥掉这个含转换的谓词 | 「hive 没声明支持转换下推 → 引擎会剥」 | hive 继承了默认值 `true`(没声明过),而且 `applyFilter` 这条路根本不查这个位 | 只有残余谓词那条路才会剥;`applyFilter` 从不剥 | +| 连接器收到什么 | 收不到这个谓词 | 收到 `dt = 20240101`(转换外壳被正向转换器拆掉了) | 连接器收到的比较可能已被拆壳,且无从自查 | +| 后果 | 无 | hive 用它去裁元存储分区;若源侧的字符串/数字比较语义与 Doris 不同,就少扫分区、少返回行 | 明确写出这个风险由连接器承担 | + +「新人照文档写」的另一面,用第 6 条一句话说明:新连接器实现 `listPartitions` 时,按文档「值可以不填,fe-core 会解析名字」只填 `partitionName` —— 今天的真实结果是这张表所有分区都被静默跳过,表变成「无分区」。 + +## 五、解决方案 + +### 5.1 目标状态 + +除下面这一条待拍板项外,**全部是 javadoc 文字改动,零签名改动、零行为改动**。 + +待拍板项:`ConnectorPushdownOps.supportsCastPredicatePushdown` 的默认值。 + +```java +// 现状(ConnectorPushdownOps.java:72-74) +default boolean supportsCastPredicatePushdown(ConnectorSession session) { + return true; +} + +// 选项 A(调研报告的建议,安全侧):默认 false,能正确处理拆壳后比较语义的连接器显式声明 true +default boolean supportsCastPredicatePushdown(ConnectorSession session) { + return false; +} +``` + +三个选项,请你选一个: + +- **选项 A:默认翻成 false。** 与本仓库既有纪律(能力用 `supportsXxx()` 默认 false 的 opt-in 声明)一致。**新发现的代价**:今天 iceberg、hive、hudi、es、trino 五个连接器全都继承 `true`(hive 网关下挂的 iceberg-on-HMS 等异构表同样受影响),翻转会让它们在残余谓词那条路上失去含类型转换谓词的下推(正确性上更安全,但可能变慢);要恢复就得逐个连接器判断「拆壳后的比较语义与远端是否一致」,而连接器目前无从自查,这个判断没有客观依据。所以选 A 意味着一次跨五个连接器的行为改动,不适合和文档批一起走。 +- **选项 B(推荐):本批只改文档,默认值不动。** 把两条路径的差异、正向转换器拆壳的事实、以及「风险由连接器承担」写进 javadoc,保持这个批次零行为改动;默认值翻转与逐连接器判断另开一项,配端到端回归。 +- **选项 C:彻底修。** 引擎在拆壳处给该比较打标记,连接器能自查后再决定默认值。这需要在公共接口上加表达能力,超出本任务范围,只作为远期方向记录。 + +### 5.2 改动清单 + +| 文件(均在 `fe-connector-api` 下) | 位置 | 做什么 | +|---|---|---| +| `api/ConnectorContractValidator.java` | `:29-34` | 把「由每个连接器的契约测试强制」改成实测口径:今天调用它的是 iceberg / es / maxcompute / jdbc 四个连接器的测试;并补一句「按分区哈希写的两条不变量在真实连接器上没有正样本(唯一声明该能力的 hive 没调),只有 `fe-core` 的假连接器测试覆盖」;再补一句「本类校验连接器级取得器,引擎写路径读的是按表重载 `Connector.requiresPartitionHashWrite(handle)`」 | +| `api/procedure/ConnectorProcedureOps.java` | `:48-52` | 删掉「引擎用于 routing、validation」;改成:路由按表类型加执行模式,未知名由连接器在 `execute` 内拒绝;本方法唯一读取点是为 `SHOW` 类发现预留、今天无生产调用方 | +| `api/ConnectorPartitionInfo.java` | `:52-61` | 删掉「fe-core 回退解析分区名」;改成:走 MVCC 分区项路径的连接器**必须**提供该列表,留空会让 `fe-core` 的元数校验失败、该分区被跳过、整表退化成无分区(丢分区裁剪)。**同一文件 `:41-49` 关于 NULL 标记「留空=全部非 null」的描述是正确的,不要顺手改** | +| `api/mvcc/ConnectorMvccSnapshot.java` | `:25-32`、`:77` | 删掉「serialized into BE scan ranges」和「propagated to BE」;改成:引擎只在 FE 内把它当查询期 MVCC 钉子传递;`properties` 由连接器自己在 `applySnapshot` 里织进表句柄(契约见 `ConnectorMetadata.applySnapshot`),是否传到 BE 完全由连接器的扫描计划提供者决定 | +| `api/ConnectorMetadata.java` | `:177-179` | 保留「引擎不按连接器区分」(这句是对的);把「applies whatever the connector returns」改成明确的语法边界:只支持「与」+ EQ/LT/LE/GT/GE + 可按名绑定到扫描输出列的列引用 + STRING 字面量,超出即抛异常(有意 fail loud),并指向反向转换器 | +| `api/ConnectorPushdownOps.java` | `:60-74` | 按 5.1 选定的选项改。选项 B 时:写清只有残余谓词路径会剥、`applyFilter` 路径不剥、正向转换器遇 CAST 直接拆壳因而连接器无从自查、默认 `true` 意味着风险由连接器承担 | +| `api/Connector.java` | `:217` | 删掉 `Design S8:` 这个代号,保留其后的技术论述(「存储属性派生归连接器所有,fe-core 不解析元存储属性」本身就是完整理由) | +| `api/handle/ConnectorTransaction.java` | `:79` | 删掉 `(O5-2)` | +| `api/pushdown/ConnectorPredicate.java` | `:24` | 删掉 `(O5-2)` | +| 六处 `#65329`(`api/ConnectorColumn.java:61/147/217/222`、`api/ConnectorType.java:81`、`api/ConnectorTableOps.java:326`) | 同左 | 裸工单号对仓库外读者不可解。改成行为描述(「省略 COMMENT 表示保留原注释、显式空串表示清空」),确实需要留追溯线索时写全 `apache/doris#65329` | + +**与「删掉没有调用方的接口面」那个任务重叠的三行**,本任务默认**不动**,因为它们整体是删除对象,改了也会被删掉: + +| 符号 | 处置 | +|---|---| +| `ConnectorTableOps.listPartitionValues` 的文档 | 该方法在删除任务里连三个连接器实现一起删。若你决定保留它,则改文档为:零生产调用方,`partition_values()` 表函数实际走 `listPartitions` | +| `ConnectorScanRangeType` / `ConnectorScanRange.getRangeType` / `ConnectorScanPlanProvider.getScanRangeType` 的文档 | 三者在删除任务里整体删。若决定保留,则改文档为:引擎从不读它,格式差异由 `populateRangeParams` 的多态覆写决定 | +| `ConnectorEventSource.getCurrentEventId` 的文档 | 该方法在删除任务里删。若决定保留,则改文档为:零调用方,游标完全由 `pollOnce` 的结果驱动 | + +**一项可选的测试补充**(零行为改动,但不是文档):试着在 hive 已有的、已经构造过连接器的测试里(例如 `fe-connector-hive/src/test/.../HiveConnectorCapabilitiesTest.java`)加一行 `ConnectorContractValidator.validate(connector, "hive")`,让「按分区哈希写」的两条不变量在真实连接器上有正样本。注意 hive 的 `getWritePlanProvider()` 会走 `getOrCreateClient()`(`HiveConnector.java:256-258`),如果在无元存储的单测环境里构造不出来,**不要硬凑**——改为在 `ConnectorContractValidator` 的 javadoc 里如实记下这个缺口即可。 + +### 5.3 明确不要顺手做的事 + +- **不要顺手删接口。** 这九条里有三条的符号是另一个任务的删除对象;本任务不承担删除,也不承担「先标过时」。 +- **不要顺手改 `supportsCastPredicatePushdown` 的默认值**,除非你拿到的答复是选项 A;即使是 A,也要作为独立提交、配逐连接器判断与回归,不要混进文档批。 +- **不要顺手修 `ConnectorPartitionInfo` 里那条 NULL 标记的文档**(`:41-49`)——它与 `PluginDrivenMvccExternalTable.java:334-336` 的校验一致,是对的。 +- **不要顺手改 `ConnectorMetadata.getSyntheticScanPredicates` 的引擎侧行为**去支持更多表达式节点。反向转换器有意 fail loud,扩语法是另一件事。 +- **不要为「文档与实现是否一致」写 shell 或正则门禁。** 本仓库已有结论:这类门禁只适合存在性与前缀类不变量,理解语言语义的活轮不到它,误报比漏报更毒。文档一致性靠评审。 +- **不要在 `fe-core` 里补代码去迎合旧文档**(例如把删掉的分区名解析兜底加回来)。当前阶段 `fe-core` 只出不进,正确做法是改文档、让连接器提供数据。 +- **不要顺手统一异常分层、命名或重载堆叠**,那些是调研报告里另外的条目。 + +## 六、怎么验证 + +1. **编译门禁(最强单一信号)**:全反应堆含测试源编译,禁用跳过测试编译的参数。 + `mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile`(不要加 `-Dmaven.test.skip=true`)。纯 javadoc 改动本身不会破坏编译,这一步的作用是抓「顺手改了签名却没改调用方」以及 `{@link}` 指向不存在符号导致的 javadoc/checkstyle 失败。 +2. **代码风格**:javadoc 行长受 checkstyle 限制,改完跑 FE 的 checkstyle(`fe-code-style` 那套),确认零新增告警。 +3. **`{@link}` 有效性自查**:本次新增的交叉引用(`ConnectorMetadata.applySnapshot`、`Connector.requiresPartitionHashWrite(ConnectorTableHandle)`、反向转换器类名)逐个 grep 确认符号存在且可见性允许被链接(`ConnectorExpressionToNereidsConverter` 在 `fe-core` 里,连接器模块看不到它,**只能以文字提及,不能写成 `{@link}`**)。 +4. **单元测试**:选项 B(只改文档)时无需新增测试,也不需要变异验证——没有可变异的行为。若采纳选项 A,则至少要改 `JdbcConnectorMetadataTest.testDefaultPushdownOps_alwaysTrue`(`:211-216` 现在断言默认为 `true`),并为 hive / hudi / trino 各补一条「显式声明」的断言;跑测试必须禁用 maven build cache,否则 surefire 会被静默跳过而 `BUILD SUCCESS` 是空的。 +5. **端到端回归**:选项 B 不需要。选项 A 需要,且必须覆盖 hive 分区表上含隐式类型转换的等值谓词(分区裁剪结果与行数)。 +6. **代号清理的收尾检查**:在两个公共模块的 `src/main` 下重跑一次代号 grep,确认零命中: + `grep -rnE "Design [A-Z][0-9]+|\([A-Z][0-9]+-[0-9]+\)" fe/fe-connector/fe-connector-api/src/main fe/fe-connector/fe-connector-spi/src/main` + +## 七、风险与回退 + +- 文档改动的风险只有一种:**把新的错话写进去**。对策是这份文档里每条断言都带了实测位置,动手时逐条重新 grep 一遍(以符号名为准,不要相信行号),改完请一个没参与的人对着代码复核一遍文字。 +- 与删除任务的重叠已在 5.2 隔离。若删除任务先落地,本任务对应三行自然作废;若本任务先落地又不小心改了那三处,删除任务会把它们连注释一起删掉,不产生冲突,只是白做。 +- 回退成本为零:全部是注释,`git revert` 即可,无持久化格式、无有线格式、无类型标签涉及。 +- 唯一的真实风险来自选项 A(翻默认值):它会让五个连接器在残余谓词路径上少推谓词,属于「更安全但可能更慢」,且一旦为了恢复性能给某个连接器显式声明 `true`,就等于把一个无从自查的正确性风险重新打开。因此建议单独走,不与本批混提。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` 第 11.2 节:本任务的对照表来源(九行「文档说的 vs 实际」)。 +- 同一报告第十一节开头四条「有用户可见后果的缺陷」:其中隐式类型转换那条与本任务的待拍板项同源,但归责在连接器侧,另开修复。 +- 同一报告主题四(「没有调用方或没有实现方的接口面」)的 7.2、7.3 两张删除表:`listPartitionValues`、`ConnectorScanRangeType` 一族、`getCurrentEventId` 都在其中,对应本文 5.2 的重叠三行。 +- 同一报告第十节(「接口契约写得不完整」):`listFileSizes`、`resolveTimeTravel` 两条是「实现对、文档错」,处置方式与本任务同类,但属于异常契约那一批,不在本任务范围。 +- 同一报告第十四节(「被推翻或收窄的说法」):动手前值得看一眼,避免把有理由的设计当成缺陷改坏。 +- 本任务应与「把公共接口的书写规则写下来」那个任务同批提交:那份规则里应当包含「javadoc 描述引擎行为时必须给出可核实的引擎侧位置」这一条,本任务是它的第一次应用。 + +--- + +## 九、施工后订正与落地口径(2026-07-25) + +**拍板结果:选项 B**——本批只改文档,`supportsCastPredicatePushdown` 的默认值不动;翻转另开一项,需配逐连接器判断与端到端回归。 + +复核发现的订正(**以本节为准**): + +1. **第(2)条要收窄:`getRangeType()` 不是死的。** fe-core 确实从不读它,但 `fe-connector-api` 自己的 `ConnectorScanRange.populateRangeParams` **默认实现**读它,并把 `connector_scan_range_type=` 写进 `TTableFormatFileDesc` 的 jdbc 参数——这是 BE 可见的字符串,而 jdbc 是唯一不覆写 `populateRangeParams` 的连接器,所以这条默认路径是活的。**「引擎从不读」这句话必须限定为「fe-core 从不读」,并且删除分片类型枚举族的那个任务要按「会改变 jdbc 发给 BE 的内容」处理**(已在那份任务文档里加了警示)。本批未改这三处文档(按原约定留给删除批次)。 +2. **第(9)条的风险面比文档写的宽**:不只 hive,hudi 也用同样的方式从等值/`IN` 谓词裁分区(trino 则把约束转交给 trino 自己的元数据),所以「拆壳后比较语义不一致 → 少扫分区」的风险对这三个连接器都成立。已按此写进 javadoc。同时 hive 的裁剪不只吃等值,还吃未取反的 `IN` 列表。 +3. **第(7)条的读取方多一处**:`properties` 除了三个连接器的 `applySnapshot`,还有 hudi 的 `getSyntheticScanPredicates` 会读;fe-core 全程不读。已按此写。 +4. **第(10)条的代号清理已做完**:`Design S8` 1 处、`(O5-2)` 2 处、裸工单号 6 处,全部改成行为描述;两个公共模块 `src/main` 的代号正则复扫为零命中。 +5. **可选的 hive 契约测试正样本没做**:按 §5.2 末尾的处置,未硬凑;`ConnectorContractValidator` 的类文档已如实记下这个缺口(两条按分区哈希写的不变量在真实连接器上无正样本)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/09-complete-pushdown-expression-contract.md b/plan-doc/connector-public-interface-cleanup/tasks/09-complete-pushdown-expression-contract.md new file mode 100644 index 00000000000000..4cf0791140bdc8 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/09-complete-pushdown-expression-contract.md @@ -0,0 +1,211 @@ +# 09. 补全下推表达式的契约(逐算子语义 + 不可精确翻译必须放弃下推) + +> **优先级**:第二优先级(零风险) | **风险**:低 | **前置依赖**:无。本任务是任务 01、02、03 三条丢行缺陷的共同根因;那三条修复不必等本任务,但本任务落地后应回头把它们的守卫写法对齐到这里写下的契约。 +> **影响模块**:`fe-connector-api`(全部改动集中在这里)。可选:`fe-connector-paimon`(仅当决定让它改用本任务提供的公共工具方法)。 +> **预计改动规模**:1 个新增 `package-info.java`(约 120 行注释)+ 6 个已有类的 javadoc 补写(每处 10~40 行注释)+ 可选 1 个新增工具类(约 40 行)+ 1 个新增单元测试。零行为改动(除可选工具类外不新增任何可执行语句)。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +下推表达式(`org.apache.doris.connector.api.pushdown` 这一包)是引擎交给连接器的唯一谓词语言,但它今天几乎没有契约:算子的语义、字面量里能装什么 Java 对象、通配符怎么写、翻译不出来时该怎么办,全都没写。结果是七个连接器各自猜一遍规则,猜错的那几个会让查询**静默少行**。这个任务把规则写进公共模块,让下一个连接器作者不必靠读别人的实现来反推。 + +## 二、背景:现在的代码是怎么写的 + +**这个包提供什么。** `fe-connector-api` 的 `pushdown` 包有 18 个类,构成一棵中立表达式树:根接口 `ConnectorExpression` 与谓词标记 `ConnectorPredicate`,节点 `ConnectorAnd` / `ConnectorOr` / `ConnectorNot` / `ConnectorComparison` / `ConnectorIn` / `ConnectorIsNull` / `ConnectorBetween` / `ConnectorLike` / `ConnectorFunctionCall` / `ConnectorColumnRef` / `ConnectorLiteral`,入参载体 `ConnectorFilterConstraint` 与 `ConnectorColumnAssignment`,加上三个"应答"载体 `FilterApplicationResult` / `ProjectionApplicationResult` / `LimitApplicationResult`。 + +**契约总量。** 每个节点类只有一句话的 javadoc。两个最关键的: + +- `ConnectorComparison.java:24-28`: + `Binary comparison: left op right.` + `Supported operators: EQ, NE, LT, LE, GT, GE, EQ_FOR_NULL.` + 枚举本体(`:34-52`)只给了七个符号串(`EQ("=")` … `EQ_FOR_NULL("<=>")`)。没有一处说明右操作数可能是空值字面量、`EQ_FOR_NULL` 在字面量非空与为空两种情形下分别是什么意思。 +- `ConnectorLike.java:24-26`: + `A LIKE/REGEXP predicate: {@code value LIKE pattern}.` + 没有转义符、没有 `%` 与 `_` 的方言说明、没有说 `REGEXP` 是部分匹配还是整串锚定、没有说大小写敏感性。 + +**表达式是谁生产的,两条到达路径不一样。** 生产者只有 fe-core 的 `ExprToConnectorExpressionConverter`(另外两个入口 `NereidsToConnectorExpressionConverter:232-234` 与 `UnboundExpressionToConnectorPredicateConverter` 都转手复用它,所以字面量编码全仓一致)。它到达连接器有两条路,**只有第二条会剥掉带 CAST 的谓词**: + +| 路径 | 引擎侧构造点 | 是否按 `supportsCastPredicatePushdown` 剥 CAST | +|---|---|---| +| `ConnectorPushdownOps.applyFilter`(`:38-44`)收到的 `ConnectorFilterConstraint` | `PluginDrivenScanNode.java:2043-2046` `buildFilterConstraint` | **不剥**,原样给 | +| `planScan` / `getScanNodePropertiesResult` 收到的 `Optional filter` | `PluginDrivenScanNode.java:2053-2079` `buildRemainingFilter` | 剥(`supportsCastPredicatePushdown` 为 false 时) | + +且 `convertConjuncts`(`ExprToConnectorExpressionConverter.java:135-147`)在只有一个 conjunct 时**直接返回那一个节点,不包 `ConnectorAnd`**;无 conjunct 时返回布尔真字面量。 + +**字面量里实际会装什么。** `ConnectorLiteral.java:29-32` 的 javadoc 声称是 `(null, Boolean, Integer, Long, Double, String, BigDecimal, LocalDate, LocalDateTime)`。对照真实生产代码 `ExprToConnectorExpressionConverter.java:288-322`:`IntLiteral.getValue()` 返回 `long`,`FloatLiteral.getValue()` 返回 `double`,`LargeIntLiteral` 三个分支都不匹配、落到兜底 `literal.getStringValue()`。所以引擎实际产出的集合是:`null` / `Boolean` / `Long` / `Double` / `BigDecimal` / `String` / `LocalDate` / `LocalDateTime`——**`Integer` 永远不会出现**(`ConnectorLiteral.ofInt` 只被测试用到),而 `LARGEINT` 字面量会以 `String` 到达。 + +**两套"连接器吃掉了哪些谓词"的协议,只有一套真的生效。** + +| 协议 | 连接器怎么表达 | 引擎实际怎么处理 | +|---|---|---| +| `FilterApplicationResult.getRemainingFilter()`(`:45-47`) | 返回剩余表达式,或 `null` 表示全吃掉 | `PluginDrivenScanNode.java:883-892`:`null` → `conjuncts.clear()`;**非 `null` → 一个 conjunct 都不摘**(注释写明细粒度反查 deferred to a future enhancement) | +| `ScanNodePropertiesResult` 的 `notPushedConjunctIndices`(`:52-65`) | 报出**没被**吃掉的下标 | `PluginDrivenScanNode.java:1847-1892`:按下标反查并真的摘除;`hasConjunctTracking()` 为 false 时不摘 | + +现实是:全仓三个 `applyFilter` 实现(`HiveConnectorMetadata.java:1115-1116`、`HudiConnectorMetadata.java:312`、`TrinoConnectorDorisMetadata.java:292-296`)都把原表达式原样当残差返回,没人返回 `null`;而下标协议全仓只有一个实现方(`EsScanPlanProvider.java:165,220`)。也就是说今天所有连接器的谓词都会在 BE 侧被复算一遍。 + +**顺带一个文档 bug**:`ConnectorScanPlanProvider.java:446-447` 说默认实现"wraps getScanNodeProperties with an empty not-pushed set, meaning all conjuncts are assumed to have been pushed"。但 `:459-461` 走的是单参构造器 → `hasConjunctTracking = false` → 引擎**一个也不摘**。文档说的语义和真实语义正好相反。 + +## 三、为什么这是个问题 + +**第一,它是三条已知丢行缺陷的同一个根因。** 五个连接器各自实现了同一段翻译,四家蒙对一家蒙错: + +| 连接器 | `EQ_FOR_NULL` 怎么处理 | 结果 | +|---|---|---| +| iceberg(`IcebergPredicateConverter.java:245-251`) | 字面量为空 → `isNull`;非空 → `equal` | 对 | +| trino(`TrinoPredicateConverter.java:132-140`) | 同上,显式判 `isNull()` | 对 | +| maxcompute(`MaxComputePredicateConverter.java:168-174`) | 远端没有对应算子 → 落到 default → 放弃下推 | 对(且注释写明了理由) | +| es | 有专门测试覆盖 | 对 | +| paimon(`PaimonPredicateConverter.java:144-176`) | `:157-159` 先把空字面量挡掉 → `:173-174` 无条件 `builder.isNull(idx)` | **错**:`WHERE c <=> 5` 变成 `c IS NULL` | + +`ConnectorLike` 那侧同理:paimon 只要模式"不以 `%` 开头且以 `%` 结尾"就转成前缀匹配(`PaimonPredicateConverter.java:234-237`),对 `_` 和被转义的 `%` 毫无守卫;es 是唯一实现了转义处理的(`EsQueryDslBuilder.java:547-567`);jdbc 把模式原样拼进远端 SQL(`JdbcQueryBuilder.java:424-432`);`REGEXP` 在 es 直接交给 ES 的 `regexp` 查询(`:661-666`,Lucene 语义是整串锚定),在 jdbc 交给远端数据库的 `REGEXP`(多数是部分匹配)——同一棵表达式树,两种锚定语义,接口没说哪种是对的。 + +**第二,"过宽安全、过窄致命"这条最重要的规则从来没写下来,而且它有前提条件。** 用户观察到的后果是:查询不报错,只是少了行;`EXPLAIN` 看不出异常;BE 也补不回来——因为连接器已经在文件/分区级别把数据跳过了,根本没送到 BE。反过来"过宽"之所以安全,恰恰是因为引擎保留了 conjuncts 让 BE 复算(见上一节的两套协议);一旦连接器通过残差协议声明"这些谓词我全吃了",过宽就会**多返回行**。这两件事必须写在同一处,否则连接器作者只会读到半句话。 + +**第三,接口把"安全方向"当成了普适的,但它按用途反转。** 同一棵表达式树有三个用途,三种正确做法: + +| 用途 | 丢一个 conjunct 意味着 | 正确做法 | +|---|---|---| +| 扫描下推(`applyFilter` / `planScan`) | 过滤变宽 → BE 复算兜住 | 允许放弃,禁止收窄 | +| 写时冲突检测(`ConnectorTransaction.applyWriteConstraint`) | 冲突检测范围变宽 → 更保守 | 允许放弃 | +| `ALTER TABLE … EXECUTE … WHERE` 的重写范围 | 重写更多文件,极端情况整表重写 | **必须报错**,不许放弃(`UnboundExpressionToConnectorPredicateConverter.java:71-79` 已这样实现并写了注释) | + +这条只在一个 fe-core 内部类的注释里,公共模块里读不到。 + +## 四、用一个最小例子说明 + +一张 paimon 表,`c` 列有两行:`c = 5` 和 `c = NULL`。 + +| 用户写的 SQL | 今天实际发生什么 | 应该发生什么 | +|---|---|---| +| `SELECT * FROM t WHERE c <=> 5` | 连接器把它翻成 `c IS NULL`,paimon 按此裁掉含 `c=5` 的数据文件 → **返回 0 行** | 返回 1 行(`c=5`)。翻不出来就整条别下推,让 BE 自己过滤 | +| `SELECT * FROM t WHERE s LIKE 'a_c%'` | 翻成 `startsWith("a_c")` → `'abc'` 这行被数据源跳过 → **少行** | `'abc'` 应当命中。模式里有 `_` 就不能当前缀用 | + +两条的共同点:**连接器写出了一个比用户谓词更窄的过滤条件**,而更窄的下推条件在架构上是不可恢复的。契约要写的就是这一句: + +```text +翻译规则(连接器实现方必读): + 能精确表达 → 下推 + 不能精确表达 → 整条放弃(返回 null / 不吃这个 conjunct),交给 BE + 不允许 → 下推一个"差不多"的、范围更小的近似 +并且:只有在你没有通过残差协议声明"已全部吃掉"时,过宽才是安全的。 +``` + +## 五、解决方案 + +### 5.1 目标状态 + +**(1)包级说明。** 新增 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/pushdown/package-info.java`,把下面六件事一次写清(英文 javadoc,与仓库其余注释一致): + +1. 这棵树是谁生产的、两条到达路径的差别(`applyFilter` 拿到的表达式**未剥** CAST;`planScan` / `getScanNodePropertiesResult` 拿到的已按 `supportsCastPredicatePushdown` 剥过);单 conjunct 时根节点不是 `ConnectorAnd`;空 conjunct 时是布尔真字面量。 +2. **总则**:不能精确表达就整条放弃,禁止返回更窄的近似;并说明"过宽为何安全"的前提是引擎仍保留 conjuncts。 +3. 安全方向按用途反转的三行表(扫描 / 写冲突 / EXECUTE 重写),指向 `applyWriteConstraint` 与 `UnboundExpressionToConnectorPredicateConverter` 的既有注释。 +4. 字面量取值域:引擎实际产出的 8 种 Java 类型,逐 Doris 类型对照(含 `TINYINT`/`SMALLINT`/`INT`/`BIGINT` 一律 `Long`、`FLOAT`/`DOUBLE` 一律 `Double`、`LARGEINT` 是 `String`、`DATE` 是 `LocalDate`、`DATETIME` 是 `LocalDateTime`、其余类型兜底为 `Expr.getStringValue()` 的 `String`),并写明 `Integer` 不会出现。 +5. 两套残差协议的**实际效力**(照抄第二节那张表的结论),明确"返回 remainingFilter 不等于引擎会替你摘 conjunct"。 +6. 零参数 `ConnectorFunctionCall` 的陷阱:`ExprToConnectorExpressionConverter.java:342-354` 对无法识别的表达式会构造一个**函数名是原始 Doris SQL 文本、参数列表为空**的 `ConnectorFunctionCall`。它不是函数调用,连接器不得按函数名匹配语义(jdbc 在 `JdbcQueryBuilder.java:492-495` 刻意把它当预渲染 SQL 片段处理)。 + +**(2)逐算子语义写到类上。** `ConnectorComparison` 的 javadoc 补出七个算子的语义,其中 `EQ_FOR_NULL` 必须写成两种情形: + +```java +/** + * ... + *

      EQ_FOR_NULL ({@code <=>}) is Doris' null-safe equality and has TWO cases that MUST be + * distinguished; collapsing them loses rows:

      + *
        + *
      • right operand is a NULL literal ({@code ConnectorLiteral#isNull()}): + * equivalent to {@code IS NULL}.
      • + *
      • right operand is a NON-NULL literal: equivalent to plain {@code EQ} — + * it is NOT {@code IS NULL}. Translating {@code c <=> 5} to {@code c IS NULL} + * silently drops every matching row.
      • + *
      + *

      A connector whose dialect has no null-safe form must drop the whole conjunct + * (see the package javadoc); it must never substitute a narrower predicate.

      + */ +``` + +**(3)`ConnectorLike` 的方言写清**:`%` 匹配任意长度、`_` 匹配单字符、转义符是反斜杠(因此模式里 `\%` `\_` 是字面量)、`REGEXP` 是**部分匹配**(Doris 语义,未锚定)、大小写敏感性随列的排序规则、以及一条已核实的表示能力边界——Doris 的 `LIKE … ESCAPE ` 三参形态(`nereids/trees/expressions/Like.java:46,106-116`)**无法**用 `ConnectorLike` 表示(它只有 value/pattern 两个孩子,且 `ExprToConnectorExpressionConverter.java:117-122` 只在 `size() == 2` 时才构造它),因此连接器只需按固定的反斜杠转义符处理。 + +**(4)修正三处与实现矛盾的文档**(都在下推与扫描应答面上,见 5.3 的分工约定):`ConnectorLiteral` 的 Java 类型清单、`FilterApplicationResult.getRemainingFilter` 的实际效力、`ConnectorScanPlanProvider.java:446-447` 那句说反了的默认语义。 + +**(5)可选的中立工具方法(一个类、一个方法)。** 只做真正能消灭一类缺陷的那一个: + +```java +public final class ConnectorLikePatterns { + private ConnectorLikePatterns() {} + + /** + * Returns the literal prefix iff {@code pattern} is exactly "literal text + one trailing %" + * (no other %, no _, no backslash escape). Empty otherwise — the caller must then NOT + * narrow the predicate to a prefix match. + */ + public static Optional exactPrefix(String pattern); +} +``` + +`EQ_FOR_NULL` 那侧**不加**工具方法:判断本身只有一行(算子 + `isNull()`),而 iceberg / trino 已各自写对,为了复用去改它们等于给零收益的改动加上非零风险。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe-connector-api/…/api/pushdown/package-info.java`(新增) | 写入 5.1(1) 的六节包级契约 | +| `fe-connector-api/…/api/pushdown/ConnectorComparison.java` | 类 javadoc 补逐算子语义,`EQ_FOR_NULL` 两种情形;不动枚举与字段 | +| `fe-connector-api/…/api/pushdown/ConnectorLike.java` | 类 javadoc 补通配符/转义/锚定/大小写,写明三参 ESCAPE 不可表示 | +| `fe-connector-api/…/api/pushdown/ConnectorLiteral.java` | 修正类 javadoc 的 Java 类型清单(去掉 `Integer`、补 `LARGEINT` 走 `String`),`getValue()` 上补一句"空值字面量合法,比较算子必须先判 `isNull()`" | +| `fe-connector-api/…/api/pushdown/FilterApplicationResult.java` | `getRemainingFilter()` 上写明:非 `null` → 引擎不摘任何 conjunct;`null` → 引擎清空 conjuncts,因此声明 `null` 前必须确保下推是**精确**的 | +| `fe-connector-api/…/api/scan/ConnectorScanPlanProvider.java` | 改正 `:446-447` 说反的默认语义;在 `getScanNodePropertiesResult` 上写明下标是**剥 CAST 之后**那份 conjunct 列表的下标,单 conjunct 时下标 0 指整棵表达式 | +| `fe-connector-api/…/api/scan/ScanNodePropertiesResult.java` | 类 javadoc 补一句:这是目前唯一真正生效的残差协议,全仓仅 es 实现 | +| `fe-connector-api/…/api/pushdown/ConnectorLikePatterns.java`(新增,可选) | 5.1(5) 的单个静态方法 | +| `fe-connector-api/src/test/…/api/pushdown/ConnectorLikePatternsTest.java`(新增,随可选项) | 见第六节 | + +### 5.3 明确不要顺手做的事 + +- **不要改任何连接器的翻译逻辑。** paimon 的两个丢行缺陷分别归任务 02 与 03,trino 的 OR 归任务 01。本任务只提供契约;如果它们已经先落地,本任务不再回改它们的代码,只在契约里引用其守卫作为示例。 +- **不要在本任务里合并那两套残差协议。** 合并的前置是先实现细粒度反查(把残差子表达式对回原始 conjunct),那是行为改动,与"零风险"矛盾。本任务只把现状写清楚。 +- **不要给 `ConnectorOr` / `ConnectorAnd` 加构造期校验。** "至少两个分支"的校验属于任务 01 的范围(它需要连带确认没有单分支构造点)。 +- **不要统一列名大小写规则。** 今天 paimon 在查找时 `toLowerCase()`(`PaimonPredicateConverter.java:152`)、jdbc 走自己的列名映射表。要不要统一是行为决策,不属于本任务;本任务最多在包级说明里如实记一句"列名大小写规则目前由各连接器自行决定"。 +- **不要写 shell 或正则的构建门禁去校验"是否收窄了谓词"。** 这需要理解 Java 布尔语义,本仓库已有结论:那类门禁只适合存在性与前缀类不变量,误报比漏报更毒。这里正确的机器化手段是单元测试。 +- **不要顺手改 `pushdown` 包外的其它陈旧文档。** 那是任务 08 的范围。为避免两个任务改同一行,约定:`pushdown` 包内的文档 + `ConnectorScanPlanProvider` / `ScanNodePropertiesResult` 上与残差协议相关的那几行归本任务,其余归任务 08。动手前请先看任务 08 的清单是否已包含这几处并划掉。 + +## 六、怎么验证 + +**编译门禁(最强的单一信号)。** 全反应堆含测试源编译: + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T 1C test-compile +``` + +不得使用任何跳过测试编译的参数。javadoc 改动会被 checkstyle 扫到(本仓库 checkstyle 也扫测试源),因此这一步同时验证注释格式合法。 + +**单元测试(仅当采纳可选工具方法时)。** 新增 `ConnectorLikePatternsTest`,断言必须编码"为什么"而不只是"是什么"——每个用例的名字直接说明它防的是哪种丢行: + +| 模式 | 期望 | 这条测试在防什么 | +|---|---|---| +| `abc%` | `Optional.of("abc")` | 正常前缀仍然可以下推,不能因为守卫过严丢掉优化 | +| `a_c%` | `Optional.empty()` | `_` 被当字面量 → 前缀匹配会漏掉 `abc` | +| `a\%c%` | `Optional.empty()` | 被转义的 `%` 是字面百分号 | +| `%abc%` | `Optional.empty()` | 前置 `%` 不是前缀匹配 | +| `a%b%` | `Optional.empty()` | 中间还有通配符 | +| `abc`(无 `%`) | `Optional.empty()` | 这是等值而非前缀,交给调用方按等值处理 | + +**变异验证。** 把 `exactPrefix` 的守卫逐条删掉(先删 `_` 检查,再删转义检查),确认对应用例各自失败。若删掉某个守卫后测试仍全绿,说明该用例没有真的覆盖它。 + +**端到端回归:本任务不需要。** 改的是注释与一个纯函数,运行时行为不变。真正需要端到端回归的是任务 01 / 02 / 03 的连接器修复(`WHERE c <=> 5` 与 `LIKE 'a_c%'` 在 paimon 表上的行数断言),本任务不要替它们跑。 + +**验收口径。** ① 上面的 `test-compile` 为 `BUILD SUCCESS`;② 5.2 表格逐行完成;③ 一个没读过这段代码的人只读 `package-info.java`,能独立回答三个问题:`c <=> 5` 该翻成什么、`LIKE 'a_c%'` 能不能翻成前缀匹配、返回 `remainingFilter` 之后引擎会不会替我摘 conjunct。 + +## 七、风险与回退 + +风险来自两处,都可控: + +- **写错契约比不写更糟。** 契约一旦写下就会被后来的连接器当权威照做。因此本文所有事实性陈述都必须在动手时复核一遍(尤其"引擎实际产出哪些 Java 类型"和"两套残差协议的实际效力"这两段);凡是没能在代码里坐实的推断,宁可写成"目前由各连接器自行决定",不要写成规则。 +- **可选工具方法有溢出风险。** 它落在公共模块,一旦有连接器调用就成了公共 API。控制手段:只提供一个方法、语义收紧到"要么给出确定的前缀、要么明确说不行",且不强制任何连接器改用它。 + +回退:注释改动 `git revert` 即可,无数据、无持久化、无有线格式影响。可选工具类若被判定不需要,单独摘掉它与它的测试即可,其余文档改动不受影响。 + +## 八、相关背景 + +- `audit-report.md`「11.1 四个有实际用户可见后果的缺陷」—— 四条会被用户看到的丢行/丢名缺陷:第(3)(4)条是本任务的直接依据,并写明这两个 paimon 缺陷与上游既有实现相同、不是本次迁移引入的回退;第(4)条的行为后果是代码路径推断,未跑端到端验证。 +- `audit-report.md` 附录 A.4 第 84 条 —— 两套残差协议只有一套生效:完整证据链。 +- `audit-report.md` 第十六节「明确不建议动的部分」第 9 项 —— 两套残差协议不合并、只补文档:这条决定就是本任务的定位。 +- 同目录任务 07(写下两个公共模块的设计规则):本任务是它在 `pushdown` 这一包上的细化;如果 07 先落地,本任务的包级说明应该引用它而不是重述。 +- 同目录任务 08(修正陈旧接口文档):文档改动的分工见 5.3 最后一条。 +- 同目录任务 01 / 02 / 03:本任务是这三条的共同根因,它们是本任务契约的第一批"使用者"。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/10-split-table-ops-by-domain.md b/plan-doc/connector-public-interface-cleanup/tasks/10-split-table-ops-by-domain.md new file mode 100644 index 00000000000000..72aee48f9b9c27 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/10-split-table-ops-by-domain.md @@ -0,0 +1,240 @@ +# 10. 把 ConnectorTableOps 按域拆成父接口,并为每域写清最少实现集 + +> **优先级**:第二优先级(零破坏重构) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe-connector-api`(主源 + 测试源)。**不改任何连接器模块,也不改 `fe-core`。** +> **预计改动规模**:新增 7 个源文件(6 个域接口 + 1 个标记注解)、1 个测试类、1 个基线资源文件;改写 `ConnectorTableOps.java`(504 行 → 约 60 行的聚合);修 9 处 javadoc 引用(分布在 5 个文件里)。约 15 个文件;净新增代码量很小,主体是方法搬移与每个接口的类文档。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +`ConnectorTableOps` 是一个 504 行、46 个方法、全部带默认实现的巨接口,八类互不相干的职责挤在一起;因为所有方法都有默认实现,一个新连接器**被编译器强制实现的方法数是 0**——编译能过,但一行也不工作,作者只能靠抄别的连接器猜该覆写哪些。本任务把它按域拆成 6 个父接口,`ConnectorTableOps` 保留为它们的聚合,并**在每个域的类文档里写清「最少实现集」**,同时给最少实现集一个机器可读的标记。这是零破坏重构:连接器一行不改、编译期完全兼容。 + +## 二、背景:现在的代码是怎么写的 + +`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorTableOps.java`,共 504 行,接口声明在第 40 行,里面 46 个方法**全部**是 `default`(已逐行核实:文件内不存在任何抽象方法)。按声明顺序,它混着这些职责: + +| 职责 | 方法(行号) | 方法数 | +|---|---|---| +| 表句柄解析与表名列举 | `getTableHandle`(43)、`listTableNames`(177) | 2 | +| 系统表发现 | `listSupportedSysTables`(57)、`getSysTableHandle`(70)、`isPartitionValuesSysTable`(88) | 3 | +| 读 schema / 列句柄(各含一个带快照的重载) | `getTableSchema`(94, 108)、`getColumnHandles`(133, 154)、`supportsColumnHandleSnapshotPin`(172) | 5 | +| 渲染建表语句 / 表注释 / 主键 | `renderShowCreateTableDdl`(127)、`getTableComment`(423)、`getPrimaryKeys`(417) | 3 | +| 视图 | `viewExists`(187)、`listViewNames`(196)、`getViewDefinition`(207)、`dropView`(218) | 4 | +| 表级 DDL | `createTable`(223 旧窄重载, 241 全量重载)、`dropTable`(252)、`renameTable`(259)、`truncateTable`(274) | 5 | +| 列演进(含嵌套列) | `addColumn`(286)、`addColumns`(292)、`dropColumn`(298)、`renameColumn`(304)、`modifyColumn`(314)、`reorderColumns`(320)、`addNestedColumn`(340)、`dropNestedColumn`(346)、`renameNestedColumn`(352)、`modifyNestedColumn`(363)、`modifyColumnComment`(369) | 11 | +| 分支与标签 | `createOrReplaceBranch`(375)、`createOrReplaceTag`(381)、`dropBranch`(387)、`dropTag`(393) | 4 | +| 分区规格演进 | `addPartitionField`(399)、`dropPartitionField`(405)、`replacePartitionField`(411) | 3 | +| 执行裸 SQL / 透传查询取列 | `executeStmt`(432)、`getColumnsFromQuery`(440) | 2 | +| 构造 thrift 表描述符 | `buildTableDescriptor`(464) | 1 | +| 分区列举 | `listPartitionNames`(476)、`listPartitions`(487)、`listPartitionValues`(499) | 3 | + +合计 46。它被 `ConnectorMetadata` 继承(`ConnectorMetadata.java:44-51` 的 `extends` 列表,`ConnectorTableOps` 在第 46 行)。 + +**本任务成立的关键事实(已在 `HEAD` 上重新核实)**:全仓库没有任何一处把 `ConnectorTableOps` 当成静态类型使用——没有变量、参数、返回值、字段、泛型实参用它。全部命中只有三类: + +- `ConnectorMetadata.java:46` 的 `extends` 一行; +- javadoc 与注释里的引用:`fe-core` 侧 8 处(`PluginDrivenExternalCatalog.java:393/572/651/692/797/903/1003/1084`,全是 `{@code}` 文本)、`fe-connector-api` 侧 11 处非声明引用; +- 各连接器里的分节注释(如 `HiveConnectorMetadata.java:386` 的 `// ========== ConnectorTableOps ==========`),以及 `fe-core` 测试里的一处分节注释(`FakeConnectorPluginTest.java:114`)。 + +其中真正需要在本任务里动的是 7 处 **成员级** javadoc 链接:`ConnectorCapability.java:36`(指向 `getColumnsFromQuery`)、`:42`(`listPartitions`)、`:89`(`viewExists`)、`:90`(`listViewNames`),以及 `ConnectorColumnPosition.java:24-25`(`addColumn` / `modifyColumn`)、`ConnectorMvccPartitionView.java:29`(`listPartitions`),另有两处成员级的 `{@code}` 文本引用同样会因拆分变成陈旧描述,要一并改准:`ConnectorViewDefinition.java:27`(`ConnectorTableOps.getViewDefinition`)与 `ConnectorCreateTableRequest.java:30`(`ConnectorTableOps.createTable(session, request)`)。指向类型本身的链接(`ConnectorCapability.java:175`、`ConnectorColumnPath.java:28`)不用动,因为聚合接口名保留。 + +## 三、为什么这是个问题 + +**第一,「新连接器该覆写什么」这个信息今天在代码里根本不存在。** `ConnectorMetadata` 加它继承的 6 个 `Ops` 子接口一共 81 个方法,`default` 计数分别是:`ConnectorMetadata` 自身 11、`ConnectorSchemaOps` 7、`ConnectorTableOps` 46、`ConnectorPushdownOps` 4、`ConnectorStatisticsOps` 5、`ConnectorWriteOps` 5、`ConnectorIdentifierOps` 3——**抽象方法 0 个**。也就是说 `class XConnectorMetadata implements ConnectorMetadata { }` 是一个合法的、能编译过的空实现。 + +**第二,「必须实现」的方法里有一半的默认值是静默的空值,不是报错。** 这才是「编译能过但一行不工作」的具体机制:`getTableHandle` 默认返回 `Optional.empty()`(46 行)、`listTableNames` 默认返回空列表(179 行),都不报错;只有 `getTableSchema`(97) 与 `getColumnHandles`(136) 是 fail-loud 的。所以一个漏实现的连接器不会在启动时炸,而是在用户面前表现成「目录是空的」。 + +**第三,连维护者自己都记不清有没有强制方法。** `fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/ConnectorSchemaOpsDefaultsTest.java:38` 的注释写的是「A bare metadata implementing only the one abstract SPI method」——这句话在 `HEAD` 上已经是错的(没有抽象方法了)。文档与代码脱节到这个程度,说明「靠口头传承最少实现集」不可行。 + +**第四,四组只有一个数据源用得上的方法对其余连接器是纯噪音**:分支与标签(4 个)、分区规格演进(3 个)、执行裸 SQL、透传查询取列。一个只读连接器的作者要在 46 个方法里逐个判断「这个跟我有关吗」。 + +需要说明的是:**目标不是把接口拆到多小**。Trino 的 `ConnectorMetadata` 是上百个全默认方法的巨接口,同样靠文档告诉实现者最少实现集——所以 46 个全默认方法本身不异常。真问题是**没有分域、也没有写最少实现集**。 + +调研实测的覆写分布可以直接当作最少实现集的证据(统计口径:各连接器元数据实现类里 `@Override` 且方法名属于 `ConnectorTableOps`): + +| 连接器 | 覆写的 `ConnectorTableOps` 方法名个数 | +|---|---| +| iceberg | 35 | +| hive | 31 | +| paimon | 13 | +| maxcompute | 10 | +| jdbc | 9 | +| hudi | 8 | +| es | 5 | +| trino | 4 | + +8 个连接器**无一例外**都覆写的是 4 个:`getTableHandle`、`listTableNames`、`getTableSchema`、`getColumnHandles`。再加上 8 个里有 7 个覆写的 `buildTableDescriptor`(唯一的例外是 trino,它吃引擎的通用兜底描述符),构成「表基础」域的无条件最少实现集,不是拍脑袋定的。 + +## 四、用一个最小例子说明 + +假设我要新增一个连接器 X。我写下这一个类,它能编译过: + +```java +public class XConnectorMetadata implements ConnectorMetadata { +} +``` + +然后用户在 `x` 目录上做这几件事: + +| 用户写了什么 | 今天实际发生什么 | 应该发生什么 | +|---|---|---| +| `SHOW TABLES FROM x.db` | 返回**空列表**,不报错(`listTableNames` 默认返回空列表) | 作者一开始就知道 `listTableNames` 属于「表基础」域的无条件最少实现集 | +| `SELECT * FROM x.db.t` | 报「表不存在」(`getTableHandle` 默认返回 `Optional.empty()`),像是用户打错了表名 | 同上,`getTableHandle` 在最少实现集里 | +| `DESC x.db.t` | 抛 `getTableSchema not implemented` | 唯一一条今天就能告诉作者「你漏了」的路径 | +| `ALTER TABLE x.db.t ADD BRANCH b1` | 抛「CREATE/REPLACE BRANCH not supported」 | 这一族本就与 X 无关;拆分后它在独立的「快照引用」域里,作者一眼就知道整域可以不看 | + +第一行的「返回空列表不报错」有测试固化:`fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java:117-118`(`// SHOW TABLES against an unimplemented connector returns empty rather than throwing.`)。这是有意的设计,不是缺陷——但正因为默认值是静默的,「哪些必须实现」就必须写在文档里,否则无处可寻。 + +## 五、解决方案 + +### 5.1 目标状态 + +`ConnectorTableOps` 变成一个不声明任何方法的聚合接口(外加两个暂未归域的残留方法),46 个方法按域分到 6 个新接口。**签名一个字都不改,包名不变(都在 `org.apache.doris.connector.api`)。** + +```java +public interface ConnectorTableOps extends + ConnectorTableMetadataOps, // 表基础:14 + ConnectorViewOps, // 视图:4 + ConnectorTableDdlOps, // 表级 DDL:5 + ConnectorColumnEvolutionOps, // 列演进:11 + ConnectorSnapshotRefOps, // 快照引用与分区规格演进:7 + ConnectorPartitionListingOps { // 分区列举:3 + + // 暂未归域(2 个):jdbc 直通。等「把 jdbc 直通摘成可选接口」那一批处理, + // 现在放在聚合上而不是硬塞进某个域,避免给它们一个错误的归属。 + default void executeStmt(ConnectorSession session, String stmt) { ... } + default ConnectorTableSchema getColumnsFromQuery(ConnectorSession session, String query) { ... } +} +``` + +6 个域接口与各自的最少实现集(这一节的内容就是要写进各接口类文档的正文): + +**1. `ConnectorTableMetadataOps`(14 个)**:`getTableHandle`、`listTableNames`、`getTableSchema`×2、`getColumnHandles`×2、`supportsColumnHandleSnapshotPin`、`getTableComment`、`getPrimaryKeys`、`renderShowCreateTableDdl`、`listSupportedSysTables`、`getSysTableHandle`、`isPartitionValuesSysTable`、`buildTableDescriptor`。 +- 无条件必须:`getTableHandle`、`listTableNames`、`getTableSchema(session, handle)`、`getColumnHandles(session, handle)`(8/8 连接器全覆写)、`buildTableDescriptor`(8 个里 7 个覆写,只有 trino 未实现;它的唯一消费方是 `PluginDrivenExternalTable.java:1343`,返回 `null` 会退到引擎的通用兜底描述符,这也是 trino 今天能不实现的原因)。 +- 支持时间旅行或模式演进才必须:`getTableSchema(..., snapshot)`、`getColumnHandles(..., snapshot)`、`supportsColumnHandleSnapshotPin`(三者要么全实现要么全不实现;只实现前两个而不声明第三个,会让引擎跳过「绑定列必须有句柄」的 fail-loud 检查)。 +- 暴露系统表才必须:`listSupportedSysTables` + `getSysTableHandle`;`isPartitionValuesSysTable` 只在该系统表走通用分区值表函数时覆写。 +- 其余按需:`getTableComment`、`getPrimaryKeys`、`renderShowCreateTableDdl`。 + +**2. `ConnectorViewOps`(4 个)**:`viewExists`、`listViewNames`、`getViewDefinition`、`dropView`。 +- 最少实现集:整域可空。声明 `ConnectorCapability.SUPPORTS_VIEW` 后,`viewExists` + `getViewDefinition` 必须(否则 `getViewDefinition` 的默认会抛);`listViewNames` 只在 `listTableNames` **不**含视图时必须(iceberg 属于这种);`dropView` 只在支持 `DROP VIEW` 时。 + +**3. `ConnectorTableDdlOps`(5 个)**:`createTable`×2、`dropTable`、`renameTable`、`truncateTable`。 +- 最少实现集:支持建表就必须实现 `createTable(session, request)` 这个**全量**重载,**不要**只实现旧的窄重载——全量重载的默认实现(241-249 行)会把 `PARTITION BY` / 分桶 / `EXTERNAL` / `IF NOT EXISTS` 静默丢掉,只实现窄签名的后果是「建表成功但分区丢了」。这一条必须在类文档里写成警告。 +- 其余按需:`dropTable`、`renameTable`、`truncateTable`。 + +**4. `ConnectorColumnEvolutionOps`(11 个)**:顶层 6 个 + 嵌套 4 个 + `modifyColumnComment`。 +- 最少实现集:整域可空。支持列变更时顶层 6 个(`addColumn`、`addColumns`、`dropColumn`、`renameColumn`、`modifyColumn`、`reorderColumns`)成组实现(hive 与 iceberg 都是这 6 个全覆写);嵌套 4 个 + `modifyColumnComment` 只在支持嵌套列演进时(目前只有 iceberg)。原文件 325-333 行那段说明嵌套路径约定的注释要整段搬到这个接口的类文档里。 + +**5. `ConnectorSnapshotRefOps`(7 个)**:分支标签 4 个 + 分区规格演进 3 个。 +- 最少实现集:整域可空,这是给「有快照引用概念」的数据源的。要点:分支/标签 4 个方法要么全实现要么全不实现——只实现一半会让 `CREATE BRANCH` 成功而 `DROP BRANCH` 报「不支持」。 + +**6. `ConnectorPartitionListingOps`(3 个)**:`listPartitionNames`、`listPartitions`、`listPartitionValues`。 +- 最少实现集:分区表连接器必须 `listPartitionNames` + `listPartitions`。`listPartitionValues` **不要**实现:它零生产调用方(详见任务清单里删死接口那一批),文档必须点明,否则新作者会照着现有的三个实现继续抄。 + +**机器可读的标记**:在 `fe-connector-api` 新增一个纯文档用途的注解,作为最少实现集的**唯一真源**(类文档负责解释「为什么」,注解负责「是哪些」): + +```java +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ConnectorMustImplement { + /** 空串 = 无条件必须;否则写触发前提(能力名或一句话条件)。 */ + String when() default ""; +} +``` + +`RUNTIME` 保留期只为让单元测试能反射读到它。**生产代码不读它,不做任何运行时校验,也不加 shell/正则门禁**——本仓库已有结论:那类门禁只适合存在性与前缀类不变量,要理解语言语义就会误报,而误报比漏报更毒。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `.../connector/api/ConnectorTableMetadataOps.java` | 新建。搬入 14 个方法(含两组带快照的重载与三个系统表方法)+ 类文档写最少实现集 | +| `.../connector/api/ConnectorViewOps.java` | 新建。搬入 4 个视图方法 + 类文档 | +| `.../connector/api/ConnectorTableDdlOps.java` | 新建。搬入 5 个表级 DDL 方法 + 类文档(含「别只实现窄重载」警告) | +| `.../connector/api/ConnectorColumnEvolutionOps.java` | 新建。搬入 11 个列演进方法 + 原 325-333 行嵌套路径说明整段搬入类文档 | +| `.../connector/api/ConnectorSnapshotRefOps.java` | 新建。搬入分支标签 4 个 + 分区规格演进 3 个 + 类文档 | +| `.../connector/api/ConnectorPartitionListingOps.java` | 新建。搬入 3 个分区列举方法 + 类文档(含 `listPartitionValues` 无调用方的提示) | +| `.../connector/api/ConnectorMustImplement.java` | 新建注解 | +| `.../connector/api/ConnectorTableOps.java` | 改写成 `extends` 6 个域接口的聚合,只保留 `executeStmt` / `getColumnsFromQuery` 两个残留方法;`import` 相应收缩 | +| `.../connector/api/ConnectorCapability.java` | 改 4 处成员级 javadoc 链接(36 / 42 / 89 / 90 行)指向新接口 | +| `.../connector/api/ddl/ConnectorColumnPosition.java` | 改 2 处链接(24-25 行)指向 `ConnectorColumnEvolutionOps` | +| `.../connector/api/mvcc/ConnectorMvccPartitionView.java` | 改 1 处链接(29 行)指向 `ConnectorPartitionListingOps` | +| `.../connector/api/ConnectorViewDefinition.java` | 改 1 处 `{@code}` 文本引用(27 行)指向 `ConnectorViewOps.getViewDefinition` | +| `.../connector/api/ddl/ConnectorCreateTableRequest.java` | 改 1 处 `{@code}` 文本引用(30 行)指向 `ConnectorTableDdlOps.createTable(session, request)` | +| `.../api/src/test/.../ConnectorMetadataSurfaceTest.java` | 新建(见第六节) | +| `.../api/src/test/resources/connector-metadata-methods.txt` | 新建基线:拆分**前**生成的 `ConnectorMetadata` 方法签名清单 | + +搬移时的三个机械要点: + +1. **默认实现体里的方法调用不跨域**,已逐条核实:`getTableSchema(..., snapshot)` 调 `getTableSchema(...)`(同域)、`getColumnHandles(..., snapshot)` 调 `getColumnHandles(...)`(同域)、`createTable(request)` 调 `createTable(schema, props)`(同域)。所以 6 个域接口**互不继承**,也不会出现同一方法在两个域里声明的钻石问题。 +2. `import` 要按域重新分配,`UnusedImports` 与 `CustomImportOrder`(`fe/check/checkstyle/checkstyle.xml:160-167`)会卡住遗漏。 +3. 域接口内的 `{@link #xxx}` 若目标方法落在别的域里,要改成 `{@link ConnectorXxxOps#xxx}`。已知一处:`listViewNames` 的文档引用了 `listTableNames`。 + +### 5.3 明确不要顺手做的事 + +- **不要改任何方法签名,不要合并重载,不要删任何方法。** 删 `listPartitionValues`、收 `createTable` 旧窄重载、删 `getPrimaryKeys`,都在「删死接口」那两批里,各自有独立的连带改动(要动连接器与单测)。本任务混进去就不再是零破坏,也会让第六节那条「方法集合完全一致」的断言失效。 +- **不要动连接器**,包括那些 `// ========== ConnectorTableOps ==========` 分节注释:聚合接口名保留,注释仍然准确;改它会把一个纯公共模块的改动扩散成 8 个模块。 +- **不要把 `buildTableDescriptor` 的 7 个散列标量参数改成传句柄。** 那是 thrift 边界那一批的事,本任务只给它安个域。 +- **不要给注解加运行时校验**,也不要在 `Connector` 注册路径上做任何检查——那会把每个连接器的元数据对象构造提前,本仓库已有先例说明代价(见 `ConnectorContractValidator` 类文档解释为什么校验放在契约测试而不是注册路径)。 +- **不要顺手把另外 5 个 `Ops` 子接口也拆了。** 它们分别只有 3~7 个方法,不构成可发现性问题;本任务只需要在各自类文档里补最少实现集就够,但那属于「文档据实」那一批。 +- **不要写 shell 门禁**去校验「新连接器是否实现了标记方法」。 + +## 六、怎么验证 + +**第一,全反应堆含测试源编译(最强的单一符号级信号)**: + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile -Dmaven.build.cache.enabled=false +``` + +必须 `BUILD SUCCESS`,且**禁用**任何跳过测试编译的参数。这一条通过就直接证明了零破坏:8 个连接器与 `fe-core` 的全部 `@Override` 仍然绑得上。(注意 `fe/.mvn/maven.config` 已带 `-Dmaven.build.cache.cacheCompile=false`,但跑测试时仍要显式关掉整个构建缓存,否则 surefire 会被静默跳过而 `BUILD SUCCESS` 是空的。) + +**第二,方法集合冻结测试**(新建 `ConnectorMetadataSurfaceTest`,junit5,与 `fe-connector-api` 现有测试同风格): + +1. **拆分前**在 `HEAD` 上生成基线:反射 `ConnectorMetadata.class.getMethods()`,过滤掉 `isSynthetic()`,把「方法名 + 参数类型全限定名列表」渲染成每行一条、排序后写入 `src/test/resources/connector-metadata-methods.txt`。按上面的计数,基线应为 **81 条**(11+7+46+4+5+5+3;`close()` 已被 `ConnectorMetadata.java:232` 的默认实现覆盖,计在 11 里)——但要**机械生成**,不要照抄这个数字。 +2. 测试断言:运行时算出的集合与基线文件**完全相等**(不只是数量相等;不相等时把差集打印出来,方便判断是漏搬还是签名手误)。 + - 这条能失败的场景:搬移时手抖改了参数类型、漏搬一个方法、把某个重载写成了同一签名。 + - 这条测试在后续「删死接口」批次里会红——那是**故意的**,基线文件必须跟着那一批一起有意识地更新,这正是给公共接口加的减速带。类文档要写明这一点。 +3. 断言每个 `@ConnectorMustImplement` 标记都落在 6 个域接口之一**自己声明**的方法上(用 `getDeclaredMethods()` 判定),且 6 个域接口各至少有一个标记或在类文档里显式说明「整域可空」——防止标记随手打在聚合接口上,让「域 → 最少实现集」这个映射保持成立。 +4. 断言无条件标记(`when()` 为空串)的方法集合恰好是那 5 个(`getTableHandle`、`listTableNames`、`getTableSchema(session, handle)`、`getColumnHandles(session, handle)`、`buildTableDescriptor`)。这条把「前 4 个 8/8 连接器实测都覆写、`buildTableDescriptor` 8 个里 7 个覆写」这个证据钉在测试里;将来要把第 6 个方法升为无条件必须,必须先改这条断言,从而被迫说明理由。 + +**变异验证**:第 2 条断言的变异是「从任一域接口里删掉一个方法」→ 必须红;第 4 条的变异是「把 `renderShowCreateTableDdl` 也标成无条件必须」→ 必须红(它只有 hive 一个实现)。两个变异都要实际跑一遍确认会红,而不是只在脑子里推。 + +**端到端回归**:不需要。本任务不改任何签名、不改任何默认实现体、不改任何连接器,运行时字节行为逐条不变。全反应堆含测试源编译 + `fe-connector-api` 与 `fe-core` 的既有单测(尤其 `FakeConnectorPluginTest`、`ConnectorSchemaOpsDefaultsTest`)通过即可。 + +## 七、风险与回退 + +风险低,来源只有两个,都是机械性的: + +- **搬移时漏搬或改错签名。** 由第六节第 2 条断言 + 全反应堆含测试源编译双重兜底。任一环节红就是漏搬。 +- **javadoc 链接指向搬走的方法。** 影响仅限文档渲染,不影响编译。`{@link ConnectorTableOps#addColumn}` 这种写法在方法搬到父接口后仍能解析(javadoc 会查继承来的成员),所以即使漏改也不会断链;但 5.2 列的那 9 处仍应改准,避免读者被指到聚合接口上找不到方法体。 + +回退成本近似于零:改动集中在一个模块的公共接口文件,`git revert` 单个提交即可,不涉及任何持久化格式、thrift 有线格式或连接器插件包。也**不涉及** Gson 持久化的类型标签——本任务不新增也不删除任何被持久化的类型。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md`: + - **第六节「主题三:大接口把互不相干的职责捆在一起」**(6.1 现状规模的接口尺寸表、6.2 哪些不是问题、6.3 建议的形状与「无一处当静态类型用」这条有利事实); + - **附录结论 113**(`ConnectorTableOps` 把 8 类职责与两种寻址风格捆在一个 46 方法接口里,判定「部分成立」,收窄理由值得一读); + - **第十五节「建议的整治路线」第 3 批**(本任务在整套路线里的位置:仅公共模块、无风险、为后续每一批划定域边界); + - **第七节 7.2 / 7.3**(后续要删的死接口面,含 `listPartitionValues`、`getPrimaryKeys`、`createTable` 旧窄重载——本任务只给它们安域、不动它们)。 +- 与本任务紧邻的两个后续任务:把 jdbc 直通(`executeStmt` / `getColumnsFromQuery`)摘成可选接口;thrift 边界整治(`buildTableDescriptor` 的参数形状)。本任务刻意为这两个留了钩子(前者放在聚合上不归域,后者归入表基础但只安域不改形状)。 + +--- + +## 九、施工后订正(2026-07-25 落地) + +**已完成。** 6 个域接口 + 1 个注解 + 冻结测试 + 基线资源,`ConnectorTableOps` 从 504 行缩到 69 行的聚合;连接器与 fe-core 零改动。 + +复核订正(**§三、§5.1 里下列说法以本节为准**): + +1. **§114 的「带快照的三个方法要么全实现要么全不实现」被推翻。** 实测只有 paimon 3/3;hive、hudi、iceberg 各只实现 `getTableSchema(..., snapshot)`,而且 `supportsColumnHandleSnapshotPin` 的注释里明确祝福了 iceberg 走 false 那条路。类文档因此写成:只实现 schema 那个是常态,列句柄的快照重载是**更强的**一步,只有当句柄按钉住的名字建键时才实现,并同时声明 pin。 +2. **§5.1-5 的「分支/标签实现一半」在今天是假想风险**(iceberg 与 hive 都 4/4,分区规格演进也都 3/3)。但**同一失效模式在列演进域是真的**:网关委派了 6 个顶层列操作、漏了 5 个路径列操作。已作为独立缺陷修复(见 README「调研期发现、已修复的真实缺口」)。类文档按「假想风险 + 真实前例」写。 +3. **`listViewNames` 的必要条件要写准**:只有当连接器的 `listTableNames` **减掉**视图时才必须实现——今天只有 iceberg,且只在启用视图目录时才减;非视图目录的 iceberg 目录 `listTableNames` 仍含视图。 +4. **窄重载 `createTable(session, schema, properties)` 今天零实现**(4 个支持建表的连接器全部实现 request 重载),这条比文档原来的说法更有力,已写进类文档。 +5. **`ConnectorTableOps` 原有 15 个 import(不是 16)**;拆完后聚合接口零 import。 +6. **需要改的成员级引用是 9 处**,另有 2 处(iceberg 连接器里提到 `ConnectorTableOps.getTableComment` 的注释)只提聚合接口名、无需改动。跨域的 `{@link}` 只有一处(`listViewNames` → `listTableNames`),已改成指向新接口。 +7. **`@ConnectorMustImplement` 的无条件集合**最终是 5 个:取表句柄、列表名、取 schema、取列句柄、构造表描述符。冻结测试第 4 条断言把它钉住。 +8. **验证口径修正**:全反应堆 `test-compile` 必须**排除两个 shade 模块**(`fe-connector-hms-hive-shade`、`fe-connector-paimon-hive-shade`),否则 hive 相关模块必然报 `package org.apache.hadoop.hive.conf does not exist`——那是反应堆用未 shade 的 `target/classes` 解析依赖导致的,与改动无关。§六 的命令要按这个改。 +9. **两个变异都实际跑过并确认变红**:删掉某个域接口里的一个方法 → 方法集合断言红;把 `renderShowCreateTableDdl` 标成无条件必须 → 无条件集合断言红。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/11-delete-dead-surface-batch-one.md b/plan-doc/connector-public-interface-cleanup/tasks/11-delete-dead-surface-batch-one.md new file mode 100644 index 00000000000000..e97311c46484ae --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/11-delete-dead-surface-batch-one.md @@ -0,0 +1,257 @@ +# 11. 删除第一批死接口面(公共模块内部,不动连接器生产代码) + +> ## ✅ 已落地(2026-07-25),分五个提交 +> +> | 提交 | 内容 | +> |---|---| +> | `delete six pieces of dead connector surface` | 两个空句柄接口、MVCC 快照两字段、两个统计 `UNKNOWN`、`ConnectorTestResult` 子组件、`ConnectorType` 四个整表取得器 | +> | `remove the LIMIT pushdown entry point` | `applyLimit` + `LimitApplicationResult` + `tryPushDownLimit` + **冻结基线重新生成** | +> | `drop the create-table request's external flag` | `isExternal`(用户拍板:删) | +> | `drop the partition-value list nothing carries` | `ConnectorPartitionValueDef` + `initialValues` + 11 处连接器测试源实参 | +> | `delete the unwired property-descriptor mechanism` | `ConnectorPropertyMetadata` + 两个 `Connector` 取得器 + 包级说明新增规则七(用户拍板:删,等同 24 号选项二) | +> +> **动手前 22 个 agent 的复核推翻/订正了本文以下说法,正文未逐条重写,以这里为准:** +> +> 1. **本文完全没提冻结基线**(它比本文的基线提交新)。`connector-metadata-methods.txt` 第 6 行就是 `applyLimit`,必须同一提交重新生成;该文件**没有 ASF 头**,重新生成时别加。已双向变异验证。 +> 2. **第 5.2 节第 4 项漏了引擎侧第四处引用**:`PluginDrivenScanNode.pinMvccSnapshot()` 的 javadoc 里有 `{@link #tryPushDownLimit}`。fe-core 的 javadoc 插件是 `true`,**编译抓不到**。 +> 3. **第 6 节第 1 项「全反应堆 test-compile 是唯一能一次证明引用全清的动作」不成立**——它对 javadoc 引用是结构性失明的。必须配人工 grep,且 grep 清单要包含 `tryPushDownLimit` 这类只在注释里出现的名字。 +> 4. **第四节的最小例子举错了连接器**:hive **没有**关掉带类型转换的谓词下推(继承默认 `true`);关掉的是 paimon 与 maxcompute,jdbc 按会话。风险是「实现 `applyLimit`」+「关掉 cast 下推」的双重条件,不是单条件。该隐患早已登记为 `plan-doc/deviations-log.md` 的 DV-020。 +> 5. **第三节第 2 条关于两个 `UNKNOWN` 的危害论证不成立**:`Optional.of(UNKNOWN)` 在 fe-core 三个消费点与 `Optional.empty()` 行为完全相同。删除理由改为「类文档与方法签名互相矛盾」。第八节「把未知收成一种表达」也不成立——同模块还有第三个**活的** `ConnectorPartitionInfo.UNKNOWN`(-1L),hive 主源与 fe-core 都在用。 +> 6. **第 6.3 节的变异验证配方无效**:`HiveConnectorMetadataDdlTest` 直接构造 spec,根本不经过 fe-core 转换器;且该测试类在本分支上本来就红(19 用例 / 5 failures + 7 errors,改动前后逐数字一致)。 +> 7. **第 6.3 节「必须仍然断言 `hasExplicitPartitionValues()`」无法执行**:fe-core 主源与测试对该方法**零命中**。实际做法是**新增**一条断言(喂非空分区定义列表、要求置位),并做变异验证:把转换器那个布尔位写死 `false` 时只有这条变红。 +> 8. **`isExternal` 的删除理由比本文强得多**:它在任何能到达连接器的路径上都是编译期常量 `true`(`CreateTableInfo.checkEngineName` 强制置真),且 `EXTERNAL_TABLE`/`MANAGED_TABLE` 这个决策在 Doris 里不存在(`HmsWriteConverter` 硬编码 `MANAGED_TABLE`,与迁移前逐字相同)。**选项 B 若按字面做会改变行为**(DROP TABLE 不再删数据)。 +> 9. **`isExternal` 的测试改动被低估**:夹具 `stubInfo` 有 **9 个调用点**传那个尾参,只改本文列的 3 行会编译失败。 +> 10. **`ConnectorTestResult` 还有一个消费者**:引擎把整个结果对象丢进 `LOG.info`,所以 `toString()` 是活的(输出不变,因为那个 map 恒空)。删字段会孤立 `Collections`/`Map` 两个 import,checkstyle `UnusedImports` 会报错。 +> 11. **`ConnectorMvccSnapshot` 的测试有个方法叫 `equalsAndHashCodeCoverAllSixFields`**、javadoc 写着「6 个字段」,删两字段后必须改名改文案。另外「20 多个生产构造点」实为 15 个(测试侧 37 个)。 +> 12. **第 5.2 节第 1 项的 import 提示是错的**:`java.util.List` 与 `java.util.Collections` 在 `Connector.java` 里都仍被使用,import 净变化为零。 +> 13. **名字撞车清单(5.3 第二类)要补两条**:`ConnectorCapability.java` 里那句 `{@code getTableProperties()}` 指的是 fe-core 那个活方法,且是**安全相关**的(哪些连接器不能声明 SHOW CREATE TABLE,否则泄露连接密码);以及上面第 5 条的 `ConnectorPartitionInfo.UNKNOWN`。 +> 14. **第 6.1 节的构建命令要排除两个 shade 模块**,否则失败原因与本批无关(见交接文档的构建坑)。 +> +> **顺带发现、留给下一批**:`fe-core` 的 `org.apache.doris.connector.ConnectorMvccSnapshotAdapter` 全仓库零引用,是一个可删的死类。 + +> **优先级**:第三优先级(删死面) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe-connector-api`(主源 + 自带测试)、`fe-core`(引擎主源 + 测试);另有三个连接器的**测试源**各去掉一个恒为空的构造参数(`fe-connector-hive`、`fe-connector-iceberg`、`fe-connector-paimon`,生产代码零改动) +> **预计改动规模**:约 20 个文件,净减 400~550 行(其中 5 个整类删除) +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +连接器公共接口上有一批方法、字段和整个类,既没有任何连接器实现,也没有任何引擎代码消费;这一批当中有 10 项可以在**完全不碰连接器生产代码**的前提下删掉,本任务把它们删干净,让公共接口第一次真正变小而不是变大。 + +## 二、背景:现在的代码是怎么写的 + +调研报告列了约 20 项「可以直接删」的死面。逐条在 `7ff51a106f0` 上重扫之后,**这 20 项里只有 10 项真的不碰连接器生产代码**,另外 8 项各有 1 到 8 个连接器在实现或构造它们(详见 5.3)。本任务只做前 10 项。下面把这 10 项的现状讲清楚。 + +**1)连接器属性描述符机制(`ConnectorPropertyMetadata`)** +`fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPropertyMetadata.java` 是一个 120 行的泛型类,提供 `stringProperty` / `intProperty` / `booleanProperty` 等工厂,用来描述「连接器暴露哪些配置项」。`Connector.java:234-242` 上挂着两个默认方法把它返回出来: + +```java + /** Returns the table-level property descriptors. */ + default List> getTableProperties() { ... } + /** Returns the session-level property descriptors. */ + default List> getSessionProperties() { ... } +``` + +全仓库对 `ConnectorPropertyMetadata` 只有 15 处命中,全部在这个类自身和上面两个默认方法里。八个连接器一个都没有覆写这两个方法。 +**特别注意同名不同义**:真正给 `SHOW CREATE TABLE` 渲染 `PROPERTIES (...)` 的是 `fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java:768` 的 `getTableProperties()`,它返回 `Map`,由 `Env.java:4881` 消费,**是活的,不许动**。同样,`ConnectorSession.java:89` 的 `getSessionProperties()` 返回 `Map`,被 hive / iceberg / paimon / jdbc / hudi / es / maxcompute 大量读取,也**是活的,不许动**。要删的只是 `Connector` 这个接口上返回描述符列表的两个方法。 + +**2)两个空句柄接口** +`handle/ConnectorPartitionHandle.java:25` 是一个 `extends Serializable` 的空接口,全仓库只有它自己这一处命中。 +`handle/ConnectorTransactionHandle.java:23` 也是空接口,唯一引用来自同目录的 `ConnectorTransaction.java:35`(`extends ConnectorTransactionHandle`),而它的存在理由写在 `ConnectorTransaction.java:32` 的注释里:「Extends the marker ConnectorTransactionHandle so that existing APIs that traffic in opaque handles continue to work without change」。核实结果:全仓库没有任何方法以 `ConnectorTransactionHandle` 作参数或返回值,这句注释在代码里为假。 + +**3)`applyLimit` 与 `LimitApplicationResult`** +`ConnectorPushdownOps.java:53-59` 声明了默认返回 `Optional.empty()` 的 `applyLimit`,八个连接器零覆写。`pushdown/LimitApplicationResult.java` 是配套的 70 行结果类,零构造点。 +但它不是「零调用」——引擎真的在调:`fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java:911-921` 的 `tryPushDownLimit()` 调用 `metadata.applyLimit(...)`,并在 `getSplits()` 里的 `1261` 行执行。这一点很关键,见第三节。 + +**4)`ConnectorMvccSnapshot` 的描述与时间戳字段** +`mvcc/ConnectorMvccSnapshot.java:37-38` 有 `timestampMillis` 与 `description` 两个字段,配 builder setter(`151-162`)、getter(`59-66`)以及 `equals`/`hashCode`/`toString` 中的项。全仓库对这两个 setter/getter 的调用只出现在公共模块自带的 `ConnectorMvccSnapshotTest.java`。生产侧的 `ConnectorMvccSnapshot.builder()` 调用点有 20 多个(hive、iceberg、paimon、hudi 和 fe-core),全部只用 `snapshotId` / `schemaId` / `lastModifiedFreshness` / `properties`,没有一处调 `.description(...)` 或 `.timestampMillis(...)`。这个类没有 Gson 注解,也不过 thrift。 + +**5)两个统计类的 `UNKNOWN` 常量** +`ConnectorTableStatistics.java:29` 与 `ConnectorColumnStatistics.java:36` 各有一个 `public static final ... UNKNOWN` 哨兵(分别是 `(-1, -1)` 和 `(-1, -1, -1, -1)`),类文档写着「statistics 不可用时用 UNKNOWN」。全仓库对这两个常量零引用。而 `ConnectorStatisticsOps.java:33/52/67` 的真实签名是 `Optional` / `Optional`——「不可用」的约定其实是 `Optional.empty()`。 + +**6)`ConnectorPartitionValueDef` 与 `ConnectorPartitionSpec.getInitialValues`** +`ddl/ConnectorPartitionValueDef.java` 是 77 行的分区值定义类,唯一引用者是 `ddl/ConnectorPartitionSpec.java`(字段 `:48`、两个构造参数 `:52`/`:59`、getter `:79`)。而 `ConnectorPartitionSpec.java:86-88` 自己的注释就写明了它恒为空: + +> The neutral converter does not lower those value expressions into `getInitialValues()` (it stays empty), so this flag preserves the information a connector needs to reject them + +真正被消费的是布尔位 `hasExplicitPartitionValues()`(hive 用它拒绝显式分区值)。唯一生产构造点 `fe-core/src/main/java/org/apache/doris/connector/ddl/CreateTableInfoToConnectorRequestConverter.java:149` 传的就是 `Collections.emptyList()`。 + +**7)`ConnectorCreateTableRequest.isExternal`** +`ddl/ConnectorCreateTableRequest.java:115-117` 的 `isExternal()`,由 `CreateTableInfoToConnectorRequestConverter.java:75` 的 `.external(info.isExternal())` 填入。核实结果:有生产者,零消费者——八个连接器没有一个读过它,类文档也没说清「external」在连接器语境下意味着什么。(调研报告写的「零消费者、零文档」需要修正为「有生产者、零消费者、零语义文档」。) + +**8)`ConnectorTestResult` 的子组件机制** +`ConnectorTestResult.java:36`(`componentResults` 字段)、`:62-70`(`withComponents` 工厂)、`:80-83`(`getComponentResults`)以及 `:89-96`(`toString` 里的拼接)。全仓库零调用。另外 `:100-110` 的 `equals` 只比 `success` 与 `message`,**故意或无意地忽略了 `componentResults`**——留着它就是留一个「两个不等的对象相等」的坑。引擎侧只消费 `isSuccess()` 与 `getMessage()`。 + +**9)`ConnectorType` 的四个整表子列表取得器** +`ConnectorType.java:249-268` 的 `getChildrenNullable` / `getChildrenComments` / `getChildrenFieldIds` / `getChildrenCommentSpecified`,全仓库(含全部测试)零调用;实际使用的都是同类里的按索引访问器。 + +## 三、为什么这是个问题 + +三条真实伤害,逐条对应上面的现状: + +1. **公共接口在向每个新连接器收税,而收来的东西没人用。** 一个新连接器作者读 `Connector` 接口会看到两个属性描述符方法、读 `ConnectorPartitionSpec` 会看到一个分区值列表、读 `ConnectorCreateTableRequest` 会看到 `isExternal()`——他要么白花时间实现,要么白花时间确认「不实现行不行」。这批面越留越长,每一次「新增连接器要读多少接口」的评估都被它抬高。 + +2. **文档说的和代码做的不一致,会制造排查浪费。** `ConnectorTransaction` 说自己是为了兼容「以不透明句柄传递事务的既有接口」,但那种接口不存在;两个统计类说「不可用时用 `UNKNOWN`」,但签名要求的是 `Optional.empty()`——照文档写的连接器会返回 `Optional.of(UNKNOWN)`,让「没有统计」变成「行数 -1」,引擎侧对 -1 的处理和对 `empty` 的处理不是一回事。这类文档不是无害的装饰,它会把人引到错的实现上。 + +3. **`applyLimit` 留着比删掉危险,这是本批唯一有正确性含义的一项。** 引擎在 `PluginDrivenScanNode.getSplits()` 里的调用顺序是: + - `1261`:`tryPushDownLimit()` → 调 `metadata.applyLimit(...)`; + - `1318`:`buildRemainingFilter()` → 若连接器不支持带隐式类型转换的谓词下推,这里会把含类型转换的谓词**剥掉**,并把 `filteredToOriginalIndex` 置为非 null; + - `1341`:`long sourceLimit = effectiveSourceLimit(limit, filteredToOriginalIndex != null);` → 一旦剥过谓词,就把传给 `planScan` 的 LIMIT 抑制掉。第 `1326-1333` 行的注释把理由写得很清楚:连接器看到的过滤条件已经不反映被剥掉的谓词,此时若在数据源侧应用 LIMIT,取回的行会被后续在 BE 上重新求值的谓词再砍一刀,**结果少返回行**。 + + 问题在于这条安全抑制只作用于 `sourceLimit`,而 `applyLimit` 在它之前 80 行就已经调过了。也就是说:今天没人实现 `applyLimit`,所以没事;哪天有连接器实现了它(接口摆在那儿、名字又直白,这是完全可能的),它就会在「谓词已被剥掉」的情况下拿到完整 LIMIT 并把它下推下去,用户看到的是**查询少返回行**,且只在带隐式类型转换的谓词 + LIMIT 的组合下出现——极难定位。删掉这个方法与它的结果类,等于把这个陷阱拆掉;真要做 LIMIT 下推,也应该在正确的位置(谓词剥离之后)重新设计入口。 + +**不建议「先加过时标注、下个版本再删」。** 这些都是内部接口,仓库外没有实现者;打上过时标注只会让公共接口再长一岁而不缩小。分批删 + 每批全反应堆含测试源编译,已经足够安全。 + +## 四、用一个最小例子说明 + +用 `applyLimit` 这一项举例,因为它是本批唯一「留着有正确性风险」的。假设 hive 表 `t` 有 100 万行,`a` 是字符串列,用户写: + +```sql +SELECT * FROM hive_catalog.db.t WHERE a = 1 LIMIT 10; +``` + +`a = 1` 会被分析成「把字符串列隐式转成数字再比较」,也就是含隐式类型转换的谓词。 + +| 用户写了什么 | 今天实际发生什么 | 如果哪天有人实现了 `applyLimit` | +|---|---|---| +| `WHERE a = 1 LIMIT 10` | 引擎发现 hive 不支持带类型转换的谓词下推,把这个谓词剥掉;因为剥过,`sourceLimit` 被抑制成「不下推 LIMIT」;数据源扫全表,BE 端重新算 `a = 1` 并取前 10 行 → **结果正确** | `applyLimit` 在谓词剥离之前就被调用,把 `LIMIT 10` 交给了数据源;数据源在没有 `a = 1` 的情况下取 10 行还给 BE;BE 再用 `a = 1` 过滤这 10 行 → **可能只返回 1 行甚至 0 行,用户看到结果少了** | + +同一段 SQL,接口面留着与删掉的区别不在性能而在正确性。删掉之后,任何人想做 LIMIT 下推都必须重新加入口,而那时他会看到 `effectiveSourceLimit` 这条抑制并绕不过去。 + +## 五、解决方案 + +### 5.1 目标状态 + +改完之后: + +- `fe-connector-api` 少掉 5 个类文件:`ConnectorPropertyMetadata`、`handle/ConnectorPartitionHandle`、`handle/ConnectorTransactionHandle`、`pushdown/LimitApplicationResult`、`ddl/ConnectorPartitionValueDef`(其中 `ConnectorPartitionValueDef` 与 `LimitApplicationResult` 是配套删)。 +- `Connector` 接口不再有属性描述符方法;`ConnectorPushdownOps` 只剩 `applyFilter` / `applyProjection` / `supportsCastPredicatePushdown`。 +- `ConnectorTransaction` 的声明变成: + +```java +public interface ConnectorTransaction extends Closeable { +``` + +(连带删掉 `ConnectorTransaction.java:32-33` 那段说明「为兼容不透明句柄接口」的注释。) + +- `ConnectorPartitionSpec` 的两个构造收成一个,签名草案: + +```java +public ConnectorPartitionSpec(Style style, List fields); +public ConnectorPartitionSpec(Style style, List fields, + boolean hasExplicitPartitionValues); +``` + +- `ConnectorTestResult` 只剩 `success()` / `success(String)` / `failure(String)` / `isSuccess()` / `getMessage()`,`equals` 与实际字段重新一致。 +- `ConnectorMvccSnapshot` 只剩 `snapshotId` / `schemaId` / `lastModifiedFreshness` / `properties`。 +- 两个统计类不再有 `UNKNOWN`,类文档改成指向 `Optional.empty()` 这一个约定。 +- `fe-core` 的 `PluginDrivenScanNode` 不再有 `tryPushDownLimit()`;`getSplits()` 里 `1261` 那行连同上面「Attempt limit and projection pushdown via SPI protocol」的注释一起去掉(投影下推的调用在别处,不受影响)。 + +### 5.2 改动清单 + +`api` 指 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/`。 + +| 序号 | 文件 | 做什么 | +|---|---|---| +| 1 | `api/ConnectorPropertyMetadata.java` | 整文件删除 | +| 1 | `api/Connector.java`(234-242) | 删两个默认方法 `getTableProperties()` / `getSessionProperties()`,清理随之不用的 import(`List` 视其它用法保留) | +| 1 | `fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java`(177-178) | 删两行断言;该测试方法其余断言保留 | +| 2 | `api/handle/ConnectorPartitionHandle.java` | 整文件删除 | +| 3 | `api/handle/ConnectorTransactionHandle.java` | 整文件删除 | +| 3 | `api/handle/ConnectorTransaction.java`(32-35) | 去掉 `extends ConnectorTransactionHandle` 与对应注释段 | +| 4 | `api/ConnectorPushdownOps.java`(53-59 及 `LimitApplicationResult` import) | 删 `applyLimit` 默认方法 | +| 4 | `api/pushdown/LimitApplicationResult.java` | 整文件删除 | +| 4 | `fe-core/.../datasource/scan/PluginDrivenScanNode.java`(44、903-921、1260-1261) | 删 import、删整个 `tryPushDownLimit()` 方法及其 javadoc、删 `getSplits()` 里的调用行与其上方注释 | +| 5 | `api/mvcc/ConnectorMvccSnapshot.java` | 删 `timestampMillis` / `description` 字段、构造赋值、getter、builder setter,以及 `equals`/`hashCode`/`toString` 中对应项;类 javadoc 相应收窄 | +| 5 | `api/src/test/.../mvcc/ConnectorMvccSnapshotTest.java` | 删对这两个字段的构造与断言;**保留**对 `snapshotId`/`schemaId`/`lastModifiedFreshness`/`properties` 的 equals/hashCode 覆盖 | +| 6 | `api/ConnectorTableStatistics.java`(23、29-30) | 删 `UNKNOWN` 常量,类 javadoc 改为「不可用时返回 `Optional.empty()`」 | +| 6 | `api/ConnectorColumnStatistics.java`(30、36-37) | 同上 | +| 7 | `api/ddl/ConnectorPartitionValueDef.java` | 整文件删除 | +| 7 | `api/ddl/ConnectorPartitionSpec.java`(30-35 javadoc、48、52-69、79、equals/hashCode/toString) | 删 `initialValues` 字段与构造参数、getter;把 `86-88` 那段解释「转换器不下降值表达式」的注释改成不再引用已删除的 getter | +| 7 | `fe-core/.../connector/ddl/CreateTableInfoToConnectorRequestConverter.java`(149) | 去掉 `Collections.emptyList()` 实参 | +| 7 | `fe-core/src/test/.../connector/ddl/CreateTableInfoToConnectorRequestConverterTest.java`(243、291、308) | 删三处 `getInitialValues().isEmpty()` 断言;**保留**同处对 `hasExplicitPartitionValues()` 的断言 | +| 7 | 连接器**测试源**去实参:`fe-connector-iceberg` 的 `IcebergSchemaBuilderTest.java`(215、239)、`IcebergConnectorMetadataDdlTest.java`(232 附近);`fe-connector-hive` 的 `HiveConnectorMetadataDdlTest.java`(187、203、223);`fe-connector-paimon` 的 `PaimonSchemaBuilderTest.java`(106、145、164)、`PaimonConnectorMetadataDdlTest.java`(59、81 附近) | 每处删掉一个 `Collections.emptyList()` 实参,其余不动。这些连接器的**生产代码零改动** | +| 8 | `api/ddl/ConnectorCreateTableRequest.java`(32 javadoc、51、69、115-117、129、143、193-196) | 删 `external` 字段、构造赋值、getter、`toString` 项、builder 字段与 setter(**见下方需拍板项**) | +| 8 | `fe-core/.../connector/ddl/CreateTableInfoToConnectorRequestConverter.java`(75) | 删 `.external(info.isExternal())` | +| 8 | `fe-core/src/test/.../CreateTableInfoToConnectorRequestConverterTest.java`(86、379、388) | 删 `isExternal()` 断言与测试夹具里的 `external` 形参/打桩 | +| 9 | `api/ConnectorTestResult.java`(28-30、36、38-45、62-70、80-83、89-96) | 删子组件字段、`withComponents`、`getComponentResults`、`toString` 里的拼接段;三个工厂改为不再传 `null`;`equals` 与剩余字段自然一致 | +| 10 | `api/ConnectorType.java`(249-268) | 删四个整表子列表取得器;**保留**同类的按索引访问器与四个底层字段(它们由按索引访问器使用) | + +**需要用户拍板的一项(第 8 项 `isExternal`)** +这是判断题不是事实题:元存储确实区分「托管表」与「外部表」,未来某个连接器可能真需要知道建的是哪种。三个选项,请选一个: + +- **选项 A(建议)**:按上表删掉。理由是「一个零消费者、零语义说明的布尔位挂在公共建表请求上」本身就在误导——连接器作者会以为自己该读它,读了又不知道该怎么用。真需要时按当时的语义重新加,成本只有几行。 +- **选项 B**:保留,但**必须同时**补两件事:一是在类文档里写明「external 在连接器语境下的确切含义」(是 `CREATE EXTERNAL TABLE` 语法位,还是元存储的表类型?),二是让至少一个连接器真正读它并据此改变行为(例如 hive 建表时决定写 `EXTERNAL_TABLE` 还是 `MANAGED_TABLE`)。 +- **选项 C(不允许)**:维持现状——零消费 + 零文档地留着。 + +调研报告里还有一项同性质的判断题(`ConnectorTableOps.getPrimaryKeys` 与 `ConnectorTableSchema.PRIMARY_KEYS_KEY`),但它要改 paimon 的生产代码,不在本批范围,留给需要连带改连接器的那一批一并拍板。 + +### 5.3 明确不要顺手做的事 + +**第一类:调研报告列在同一张表里、但核实后发现会碰连接器生产代码的 8 项——本批一律不做,留给下一批。** 之所以要写出来,是为了避免动手的人以为漏了: + +| 项 | 为什么不在本批 | +|---|---| +| `ConnectorEventSource.getCurrentEventId` | `fe-connector-hms` 的 `HmsEventSource.java:58` 有真实实现(且 `pollForMaster` 内部又自己读了一次同样的 id);删接口方法必须同时删这个覆写 | +| `ConnectorScanPlanProvider.estimateScanRangeCount` | `fe-connector-jdbc` 的 `JdbcScanPlanProvider.java:152` 有一个恒返回 1 的实现 | +| 两个 `ApplicationResult` 上的 `precalculateStatistics` | `LimitApplicationResult` 随本批整类删除,但 `FilterApplicationResult` 的这个必填参数有三个生产构造点:hive `HiveConnectorMetadata.java:1115`、hudi `HudiConnectorMetadata.java:312`、trino `TrinoConnectorDorisMetadata.java:296`(都传 `false`) | +| `ProjectionApplicationResult` 的投影列与赋值 + `ConnectorColumnAssignment` 整类 | `fe-connector-trino` 的 `TrinoConnectorDorisMetadata.java:359/369/371` 真的在构造它们 | +| `ConnectorViewDefinition.dialect` | 两个生产者:hive `HiveConnectorMetadata.java:693`(编造的占位符)与 iceberg `IcebergConnectorMetadata.java:350`(视图表示里的真方言) | +| `ConnectorProcedureOps.execute` 的 WHERE 参数 | 引擎侧确实恒传 `null`(`ConnectorExecuteAction.java:176-177`,带 WHERE 的分布式重写走 `planRewrite`),但删参数要改 `fe-connector-iceberg` 的 `IcebergProcedureOps` 实现签名 | +| `MetastoreChangeDescriptor.forTable` 的「改名后表名」参数 | 4 个生产调用点全在 `fe-connector-hms` 的 `HmsEventParser.java`(103、108、125、192),都传 `null`;真改名走 `forTableRename` | +| `ConnectorTableSchema.tableFormatType` | 构造参数,八个连接器都在传值;删它是最大的一次机械改动,必须单独一批 | + +**第二类:名字撞车、绝对不能顺手删的活代码。** + +- `ConnectorScanRange.getTableFormatType()`(`api/scan/ConnectorScanRange.java:121`)与 `ConnectorTableSchema.getTableFormatType()`(`ConnectorTableSchema.java:150`)**同名不同义**。前者是活的有线协议字段,被 `PluginDrivenScanNode.java:1793` 写进 thrift 的 `tableFormatFileDesc`。本批不碰 `tableFormatType`,但仍在这里点名,以防后续批次误删。 +- `PluginDrivenExternalTable.getTableProperties()`(返回 `Map`)与 `ConnectorSession.getSessionProperties()`(返回 `Map`)都是活的,与本批要删的 `Connector` 上两个同名方法毫无关系。 +- `fe-connector-trino` 与 `be-java-extensions/trino-connector-scanner` 里出现的 `ConnectorTransactionHandle` / `LimitApplicationResult` / `ProjectionApplicationResult` 都是 `io.trino.spi.connector.*`,是 Trino 自己 SPI 的同名类,一行都不许动。 + +**第三类:范围纪律。** +- 不要顺手把 `ConnectorTestResult` 的 `equals` 忽略字段这类问题「顺便修好」再保留字段——本批的处置就是删字段,删完 `equals` 自然一致。 +- 不要为「让删除后能编译」往 `fe-core` 新增任何数据源相关代码;本批只删不加。 +- 不要写 shell 或正则的构建门禁去防止这些符号复活。它们已经不存在了,编译就是最强的约束;这类门禁只适合存在性与前缀类不变量。 + +## 六、怎么验证 + +**1)编译(最强的单一信号)。** 全反应堆、**含测试源**: + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T 1C test-compile +``` + +禁止任何跳过测试编译的参数。本批的核心风险就是「某处还引用着已删符号」,而这条命令覆盖 `fe-core`、`fe-connector-api` 和八个连接器的主源与测试源,是唯一能一次证明「引用全清」的动作。`BUILD SUCCESS` 之外任何 symbol not found 都必须当场处理,不许注释掉测试绕过。 + +**2)单元测试(必须关掉构建缓存,否则测试会被静默跳过而仍报 BUILD SUCCESS)。** 至少跑这四组: + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -Dmaven.build.cache.enabled=false \ + -pl fe-connector/fe-connector-api,fe-core test \ + -Dtest=ConnectorMvccSnapshotTest,FakeConnectorPluginTest,CreateTableInfoToConnectorRequestConverterTest +``` + +外加三个连接器的分区相关测试(改过测试源实参的那些):`HiveConnectorMetadataDdlTest`、`IcebergSchemaBuilderTest`、`PaimonSchemaBuilderTest`、`PaimonConnectorMetadataDdlTest`、`IcebergConnectorMetadataDdlTest`。 + +**3)测试要断言的是什么(不是「还能跑」)。** +- `ConnectorMvccSnapshotTest` 删掉描述与时间戳的断言后,**必须仍然覆盖** `equals`/`hashCode` 对 `snapshotId`、`schemaId`、`lastModifiedFreshness`、`properties` 的敏感性——这四个是活的,快照身份靠它们区分。如果删完之后这个测试只剩「构造不抛异常」,那就是把测试的意图删掉了,要补回差异断言。 +- `CreateTableInfoToConnectorRequestConverterTest` 删掉 `getInitialValues()` 断言后,**必须仍然断言 `hasExplicitPartitionValues()`**。做一次变异验证:手工把 `CreateTableInfoToConnectorRequestConverter` 里传给 `ConnectorPartitionSpec` 的这个布尔位改成恒 `false`,`HiveConnectorMetadataDdlTest` 里「hive 拒绝显式分区值」那条用例必须变红。变红说明我们删掉的是死的那半(值列表),保住的是活的那半(布尔位);不变红说明保护不足,要补断言。 + +**4)删除彻底性自查(人工一次,不做成门禁)。** 对下列符号在全仓库 grep,期望零命中(`io.trino.spi.connector.*` 的同名类除外):`ConnectorPropertyMetadata`、`ConnectorPartitionHandle`、`ConnectorPartitionValueDef`、`getInitialValues`、`applyLimit(`(`ShowCommand.applyLimit` 是完全无关的同名方法,需排除)、`withComponents`、`getComponentResults`、`getChildrenNullable`、`ConnectorTableStatistics.UNKNOWN`、`ConnectorColumnStatistics.UNKNOWN`。 + +**5)端到端回归:本批不需要。** 删掉的路径在生产上全是「默认值 / 恒空 / 恒 `Optional.empty()`」,唯一涉及引擎行为的是移除 `tryPushDownLimit()`,而它调用的接口八个连接器都没实现,返回值恒为空、恒不改 handle,删掉与保留在运行时完全等价。如果手上正好有集群,跑一遍外部表带 LIMIT 的既有回归用例作为额外确认即可(端到端用例需要本地集群,不构成本批的完成条件)。 + +## 七、风险与回退 + +- **主要风险是漏删引用导致编译失败**,而这在合并前一定会被第五条第 1 项的全反应堆含测试源编译抓住,不会漏到运行期。 +- **误删活代码的风险集中在两组同名符号上**(`getTableFormatType`、`getTableProperties`/`getSessionProperties`),5.3 第二类已逐个点名;动手时按**完整类名 + 方法签名**定位,不要按方法名 grep 后批量替换。 +- **`ConnectorMvccSnapshot` 的 `equals`/`hashCode` 语义会变**(少比两个字段)。因为生产侧从不设置这两个字段(恒为 `0` 与 `""`),任何两个生产对象在这两个字段上必然相等,去掉它们不改变任何一次比较的结果。它没有 Gson 持久化也不过 thrift,无兼容包袱。 +- **回退成本极低**:建议按上表的 10 个序号拆成 10 个提交(或至少把「`applyLimit` + `LimitApplicationResult`」和「`isExternal`」各自独立成一个提交)。任何一项出问题单独 revert,不牵连其它。 + +## 八、相关背景 + +- 调研报告 `plan-doc/connector-public-interface-cleanup/audit-report.md`: + - 「主题四:没有调用方或没有实现方的接口面」第 7.1 节(为什么死面比看起来严重)、第 7.2 节(本批的原始清单,注意其中 8 项经核实需要连带改连接器,已在 5.3 移出)、第 7.4 节(不做过时标注的理由与两条判断题); + - 「主题四」第 7.3 节:需要连带改连接器的删除,下一批的范围; + - 附录 A.3「没有调用方或没有实现方的接口面」第 43–74 条:每一项的原始判定与复核收窄记录; + - 附录 B.2:几条关键结论的可复核重跑方式。 +- 本批与「主题七:语义与契约不清」第 10.2 节(数值的单位与「未知值」没有统一约定)有交集:删掉两个 `UNKNOWN` 常量,正是把「未知」的三种表达收成一种(`Optional.empty()`)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/12-delete-dead-surface-batch-two.md b/plan-doc/connector-public-interface-cleanup/tasks/12-delete-dead-surface-batch-two.md new file mode 100644 index 00000000000000..1ac5714a928997 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/12-delete-dead-surface-batch-two.md @@ -0,0 +1,256 @@ +# 12. 删除第二批死接口面(需要连带修改连接器) + +> **优先级**:第三优先级(删死面) | **风险**:中 | **前置依赖**:11 号任务(第一批删除,只动公共模块内部;先做它可以避开同文件的改动冲突,不是逻辑依赖) +> **影响模块**:`fe-connector-api`、`fe-connector-hive`、`fe-connector-hudi`、`fe-connector-iceberg`、`fe-connector-jdbc`、`fe-connector-paimon`、`fe-connector-maxcompute`、`fe-core`(**只改测试**) +> **预计改动规模**:约 18 个文件,净减少 200~260 行;其中约一半是单测的删除与改写 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +把四组「引擎从来不调用」的公共接口面从 `fe-connector-api` 删掉,并连带删掉各连接器为它们写的实现和单测;其中建表的旧宽度重载不只是死代码,它的降级默认会**静默丢掉分区信息**,是留给下一个新连接器的陷阱。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 连接器级属性取得器 `ConnectorMetadata.getProperties` + +`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:53-56`: + +```java + /** Returns connector-level properties. */ + default Map getProperties() { + return Collections.emptyMap(); + } +``` + +五个连接器覆写了它:`HiveConnectorMetadata.java:591`、`HudiConnectorMetadata.java:393`、`IcebergConnectorMetadata.java:806`、`JdbcConnectorMetadata.java:186`、`PaimonConnectorMetadata.java:452`(paimon 的覆写直接 `return Collections.emptyMap();`,即为满足接口而写的空覆写)。 + +引擎侧没有任何调用方。`fe-core` 主源里 `getProperties()` 的全部命中都属于**别的对象**:`ConnectorTableSchema`(`PluginDrivenExternalTable.java:521`)、`ConnectorDatabaseMetadata`(`PluginDrivenExternalDatabase.java:86`)、`CatalogProperty`、`CreateTableInfo` 等。名字撞车正是这一条难被发现的原因。 + +`ConnectorMetadata` 是**每语句/每次取用**构造出来的对象(`PluginDrivenMetadata.get(session, connector)`),而这个方法叫「连接器级属性」——挂错了层次。 + +### 2.2 分区值枚举 `ConnectorTableOps.listPartitionValues` + +`ConnectorTableOps.java:499`(上方注释写着 "Used by the `partition_values()` TVF and by column-distinct-value optimizations"): + +```java + default List> listPartitionValues(ConnectorSession session, + ConnectorTableHandle handle, + List partitionColumns) { + return Collections.emptyList(); + } +``` + +三个连接器实现了它:`HudiConnectorMetadata.java:690`、`PaimonConnectorMetadata.java:1146`、`MaxComputeConnectorMetadata.java:300`。三份实现都在做同一件事:拿到分区列表,再按调用方给的列顺序把值投影成二维表。 + +而注释里说的两个用途,代码上都不经过它: + +- `partition_values()` 表函数:`MetadataGenerator.java:2035` → `PluginDrivenExternalTable.getNameToPartitionValues`(`PluginDrivenExternalTable.java:882`)→ **`metadata.listPartitions(...)`**(`:898-899`),FE 侧再按分区列名投影。 +- 分区系统表:`MetadataGenerator.dealPluginDrivenCatalog` → **`metadata.listPartitionNames(...)`**(`MetadataGenerator.java:1270`)。 + +也就是说「列分区」在公共接口上有三套(`listPartitions` / `listPartitionNames` / `listPartitionValues`),真正被引擎用的是前两套。 + +### 2.3 建表与删库各一个旧宽度重载 + +`ConnectorTableOps.java:222-228` 是窄形态,`:231-249` 是宽形态,宽形态的默认实现会**降级**到窄形态: + +```java + /** Creates a new table with the given schema and properties. */ + default void createTable(ConnectorSession session, + ConnectorTableSchema schema, Map properties) { + throw new DorisConnectorException("CREATE TABLE not supported"); + } + ... + default void createTable(ConnectorSession session, ConnectorCreateTableRequest request) { + ConnectorTableSchema schema = new ConnectorTableSchema( + request.getTableName(), request.getColumns(), null, request.getProperties()); + createTable(session, schema, request.getProperties()); // 分区/分桶/EXTERNAL/IF NOT EXISTS 就在这里蒸发 + } +``` + +`ConnectorSchemaOps.java:69-75` 与 `:76-86` 是同一形状:三参 `dropDatabase(session, dbName, ifExists)` 抛「不支持」,四参 `dropDatabase(..., force)` 默认丢掉 `force` 再转给三参。 + +实际实现与调用情况(已全仓核实): + +| | 窄形态实现方 | 宽形态实现方 | 引擎调用的形态 | +|---|---|---|---| +| `createTable` | **零** | hive `:1569`、iceberg `:949`、paimon `:831`、maxcompute `:379` | 宽形态(`PluginDrivenExternalCatalog.java:455`) | +| `dropDatabase` | **零** | hive `:1537`、iceberg `:895`、paimon `:945`、maxcompute `:468` | 宽形态(`PluginDrivenExternalCatalog.java:553`) | + +### 2.4 主键:两套并存的机制,两套都没有读取方 + +- `ConnectorTableOps.getPrimaryKeys`(`:416-420`):只有 jdbc 实现(`JdbcConnectorMetadata.java:259-261`,转给连接器内部的 `JdbcConnectorClient.getPrimaryKeys`)。引擎零调用,唯一调用点是 `fe-core` 的默认值测试 `FakeConnectorPluginTest.java:123`。 +- `ConnectorTableSchema.PRIMARY_KEYS_KEY`(`ConnectorTableSchema.java:80`,值为内部前缀 + `primary_keys`):只有 paimon 写入(`PaimonConnectorMetadata.java:356-358`)。它同时被登记在 `RESERVED_CONTROL_KEYS`(`ConnectorTableSchema.java:118-120`)里,而 `fe-core` 会把这个集合里的键从 `SHOW CREATE TABLE` 的 `PROPERTIES(...)` 里**全部剥掉**——所以这条链路是「写进去,然后被删掉」,没有第三个消费者。 + +补充两个必须交代清楚的事实: + +1. **流式作业的主键不走这套接口**:它用的是 `fe-core` 自己的遗留 JDBC 客户端(`StreamingJobUtils.java:405` → `JdbcClient.java:426`),与连接器 SPI 无关,删除不影响它。 +2. **paimon 用户可见的主键属性另有一行**:`PaimonConnectorMetadata.java:341` 把 paimon 自己的 `primary-key` 选项写进表属性(这是 `SHOW CREATE TABLE` 要显示的东西),**这一行不动**。 + +## 三、为什么这是个问题 + +1. **死方法在向每个新连接器收税。** 一个新连接器作者读接口时要判断这四组要不要实现;`listPartitionValues` 还带一条「内层列表顺序必须与入参列顺序一致」的契约,三个连接器各自认真实现了一遍(注释里互相引用对方),产出的结果没有任何人读。 +2. **文档把人指向错的地方。** `listPartitionValues` 的注释说它服务 `partition_values()` 表函数,实际那条路走 `listPartitions`。照文档实现的连接器会发现「我实现了但功能不生效」,然后去引擎里找不到调用点。 +3. **建表的降级默认是一个正确性陷阱。** 它今天不触发(没人实现窄签名),但它是留给下一个连接器的地雷:只实现窄签名,`CREATE TABLE ... PARTITION BY ...` 会**建表成功且不报错**,分区、分桶、`EXTERNAL`、`IF NOT EXISTS` 全部静默丢失。删库那条同理:`force` 被默认丢掉后,`DROP DATABASE ... FORCE` 会变成非级联删除。 +4. **主键有两条并行通道且都没有读取方。** 新连接器作者要在「实现 `getPrimaryKeys`」和「写 `PRIMARY_KEYS_KEY`」之间猜,而两条都不通。 +5. **命名把层次搞错了。** 「连接器级属性」挂在每会话重建的元数据对象上,且与三个同名不同义的取得器混在一起。 + +### 顺带暴露的一个既存事实(本任务不修,需要拍板) + +hudi 连接器的缓存契约注释与单测把「`partition_values()` 表函数」标成走 `listPartitionValues` 并要求**绕过缓存**取最新(`HudiConnectorMetadata.java:692`、`HudiConnectorHmsCacheTest.java:38-45` 与 `:80-91`)。但真实的表函数路径走 `listPartitions`,而 hudi 的 `listPartitions` 是**读缓存**的(`HudiConnectorMetadata.java:671-674`)。所以 hudi 的 `partition_values()` 今天最多可能落后一个缓存 TTL —— 这是删除动作揭出来的既有行为,与本次删除无因果关系。本任务只负责**不要把错的映射留在注释和测试里**,是否要把这条路径改成取最新,另开一项、由人决定。 + +## 四、用一个最小例子说明 + +假设明天有人新增一个连接器 X,他读接口时看到两个 `createTable`,选了参数少的那个实现(这是最自然的选择:窄签名的文档是 "Creates a new table with the given schema and properties",看不出它缺什么)。用户执行: + +```sql +CREATE TABLE x_catalog.db1.orders ( + id INT, + dt DATE +) +PARTITION BY LIST (dt) () +PROPERTIES ("file_format" = "parquet"); +``` + +| 用户写了什么 | 今天实际发生什么 | 应该发生什么 | +|---|---|---| +| 带 `PARTITION BY LIST (dt)` 建表 | 建表**成功**,返回 OK;远端表**没有分区**(宽形态默认把 `partitionSpec` 丢在半路),`SHOW PARTITIONS` 空 | 要么按分区建表,要么明确报错 | +| `IF NOT EXISTS` / `EXTERNAL` | 同样被静默丢弃:重复建表报「表已存在」而不是静默返回 | 按语义生效 | +| `DROP DATABASE db1 FORCE` | `force` 被默认丢弃 → 走非级联删除 → 库非空时远端报错,用户看到的是「删不掉」 | 按 `FORCE` 级联删除,或明确报「不支持」 | + +删掉窄签名之后,连接器 X 的作者在编译期就只看到一个入口,参数里明摆着 `partitionSpec` / `bucketSpec` / `isIfNotExists`;他不实现就会得到清晰的 `CREATE TABLE not supported`,而不是一张少了分区的表。 + +## 五、解决方案 + +### 5.1 目标状态 + +`fe-connector-api` 上四处删除 + 两处「把抛出点搬进保留的宽形态」: + +```java +// ConnectorMetadata:整段删除 getProperties(连注释) + +// ConnectorTableOps:删除 listPartitionValues、删除窄 createTable、删除 getPrimaryKeys; +// 宽形态自己抛出,不再降级: + /** + * Creates a table with full DDL semantics (partition, bucket, external, IF NOT EXISTS). + * Connectors that support CREATE TABLE override this. + * @throws DorisConnectorException if the connector cannot create tables + */ + default void createTable(ConnectorSession session, ConnectorCreateTableRequest request) { + throw new DorisConnectorException("CREATE TABLE not supported"); + } + +// ConnectorSchemaOps:删除三参 dropDatabase;四参自己抛出: + default void dropDatabase(ConnectorSession session, + String dbName, boolean ifExists, boolean force) { + throw new DorisConnectorException("DROP DATABASE not supported"); + } + +// ConnectorTableSchema:删除 PRIMARY_KEYS_KEY 常量,并从 RESERVED_CONTROL_KEYS 的列表里摘掉 +``` + +异常文案保持与今天**逐字一致**(`"CREATE TABLE not supported"` / `"DROP DATABASE not supported"`),这样既有的错误路径断言不会因为措辞而变化。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe-connector-api/.../ConnectorMetadata.java:53-56` | 删除 `getProperties`(如 `Map` / `Collections` 因此不再被引用,同步清 import;checkstyle 有 `UnusedImports`) | +| `fe-connector-api/.../ConnectorTableOps.java:222-228` | 删除窄 `createTable`;把「不支持」抛出移入 `:241` 的宽形态并去掉降级构造 | +| `fe-connector-api/.../ConnectorTableOps.java:416-420` | 删除 `getPrimaryKeys` | +| `fe-connector-api/.../ConnectorTableOps.java:492-503` | 删除 `listPartitionValues` 及其注释 | +| `fe-connector-api/.../ConnectorSchemaOps.java:69-75` | 删除三参 `dropDatabase`;把抛出移入 `:82` 的四参形态 | +| `fe-connector-api/.../ConnectorTableSchema.java:76-80, 118-120` | 删除 `PRIMARY_KEYS_KEY` 常量与它在 `RESERVED_CONTROL_KEYS` 里的登记 | +| `fe-connector-api/.../ddl/ConnectorCreateTableRequest.java:28-35` | 类注释里「相对旧签名多带了哪些信息」的表述改写(旧签名将不存在) | +| `fe-connector-hive/.../HiveConnectorMetadata.java:591-593` + `:204` + `:278` | 删覆写;此处 `properties` 字段除该取得器外**无人读取**,同时删字段与 `this.properties = properties;`。**构造函数签名一律不动**(全仓 31 处构造点),最宽那个构造器的 `properties` 形参因此成为未用形参——这是刻意的取舍,见 5.3 | +| `fe-connector-hudi/.../HudiConnectorMetadata.java:393-395` | 删覆写(`properties` 字段在增量读、`use_hive_sync_partition` 等处仍在用,**保留**) | +| `fe-connector-iceberg/.../IcebergConnectorMetadata.java:806-808` | 删覆写(`properties` 字段在 `:835` 等处仍在用,**保留**) | +| `fe-connector-jdbc/.../JdbcConnectorMetadata.java:186-188` | 删覆写(`properties` 字段在构造 thrift 描述符等处大量在用,**保留**) | +| `fe-connector-jdbc/.../JdbcConnectorMetadata.java:259-261` | 删 `getPrimaryKeys` 覆写(内部客户端的同名方法**不动**,见 5.3) | +| `fe-connector-paimon/.../PaimonConnectorMetadata.java:452-454` | 删空覆写 | +| `fe-connector-paimon/.../PaimonConnectorMetadata.java:356-358` | 删 `PRIMARY_KEYS_KEY` 写入;`:341` 的 `CoreOptions.PRIMARY_KEY` 写入**保留** | +| `fe-connector-paimon/.../PaimonConnectorMetadata.java:1146-1161` | 删 `listPartitionValues`;同步修 `:105`、`:1080`、`:1094`、`:1165` 与 `:318-322` 注释里对它的引用(含「三个枚举钩子共享一份缓存」改为两个) | +| `fe-connector-hudi/.../HudiConnectorMetadata.java:689-706` | 删 `listPartitionValues`;同步修 `:709-711`、`:726` 注释(`collectPartitions` 的服务对象从三个变两个,且**不要**再把 `partition_values()` 写成走这条路) | +| `fe-connector-maxcompute/.../MaxComputeConnectorMetadata.java:299-315` | 删 `listPartitionValues`;同步修 `MaxComputePartitionCache.java:41-43` 注释(三个消费者 → 两个) | + +单测改动集中列在第六节(它们是本任务的验收面,不只是「跟着改」)。 + +### 5.3 明确不要顺手做的事 + +- **不要动 jdbc 连接器内部客户端的 `getPrimaryKeys`**(`JdbcConnectorClient.java:433`、`JdbcMySQLConnectorClient.java:215`、`JdbcOceanBaseConnectorClient.java:134`)。它们是连接器内部对 JDBC `DatabaseMetaData` 的封装(含 MySQL 的 `KEY_SEQ` 重排等方言处理),不属于公共接口面。删掉 SPI 覆写后它们暂时没有调用者,是否清理属于 jdbc 连接器自己的事,另开一项。 +- **不要动任何连接器构造函数的签名。** `HiveConnectorMetadata` 有 31 处构造点,为消掉一个未用形参去改签名,风险远大于收益。 +- **不要动 paimon 写给用户看的 `primary-key` 属性**(`PaimonConnectorMetadata.java:341`)——它是 `SHOW CREATE TABLE` 的输出内容。 +- **不要顺手改 hudi `partition_values()` 的新鲜度语义**(第三节末的那条)。本任务只修注释与测试里的错映射,不改缓存路由。 +- **不要动 `ConnectorCreateTableRequest` 的字段。** 其中 `isExternal` 的删除属于第一批(11 号任务)。 +- **不要给保留下来的宽签名新增 `supportsXxx()` 能力位。** 本任务是纯删除,不新增接口面;建表能力的声明方式是 18 号任务的事。 +- **不要改 `fe-core` 的生产代码。** 本任务在 `fe-core` 只改测试(`fe-core` 只出不进)。 +- **不要为「零调用方」这个结论加静态门禁。** 本仓库已有结论:shell/正则门禁只适合存在性与前缀类不变量。 + +## 六、怎么验证 + +### 6.1 编译门禁(最强单一信号) + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T1C test-compile +``` + +必须含测试源,**禁用 `-Dmaven.test.skip=true`**。删除公共接口方法后,任何漏改的连接器实现(`@Override` 找不到父方法)与漏改的测试调用点都在这里报错——这正是本任务的兜底。 + +### 6.2 单元测试要断言什么 + +跑测试一律加 `-Dmaven.build.cache.enabled=false`(否则 surefire 会被静默跳过,`BUILD SUCCESS` 是空的)。 + +**`fe-core`(只改测试)** + +| 测试 | 怎么改 | +|---|---| +| `FakeConnectorPluginTest.java:117-126`(`tableOpsListDefaults`) | 去掉 `getPrimaryKeys` 那一行断言 | +| `FakeConnectorPluginTest.java:128-139`(`partitionListingDefaultsToEmpty`) | 去掉 `listPartitionValues` 断言,注释里的「三个枚举默认值」改成两个 | +| `FakeConnectorPluginTest.java:140-157`(`createTableRequestDefaultDegradesToLegacy`) | **改写而非删除**:改名为「未实现建表的连接器收到宽形态请求时直接抛出」,断言消息为 `CREATE TABLE not supported`,并写明为什么不再有降级(降级会静默丢分区)。这是本任务唯一的行为断言 | +| 同文件,新增一条 | 四参 `dropDatabase` 默认抛 `DROP DATABASE not supported`(今天没有这条覆盖,删掉三参之后必须补上,否则抛出点搬家没有测试托底) | +| `PluginDrivenExternalTablePartitionTest.java:245, 261` 与 `PluginDrivenExternalTableTest.java:934, 951` | 这两处用 `PRIMARY_KEYS_KEY` 作为「保留键会被剥掉」的样本。**不要整段删测试**,把样本换成另一个仍存在的保留键(如 `DISTRIBUTION_COLUMNS_KEY`),保持原意图不变 | + +**paimon** + +| 测试 | 怎么改 | +|---|---| +| `PaimonConnectorMetadataPartitionTest.java:212`(`listPartitionValuesUsesRequestedColumnOrderWithRenderedValues`) | 删除整个测试方法 | +| 同文件 `:391` | 删掉那一行 `listPartitionValues` 断言,保留 `listPartitions` / `listPartitionNames` 的「未分区表不碰远端」断言 | +| `PaimonConnectorMetadataPartitionViewCacheTest.java:288`(`listPartitionValuesCachesAcrossQueries`) | 删除整个测试方法 | +| 同文件 `:311`(`allThreeHooksShareOneCacheEntry`) | 改成两个钩子共享一份缓存条目:删掉 values 相关断言并改方法名与注释;**`loadCount == 1` 这条断言必须保留**(它是分区视图缓存的核心不变量) | +| 同文件 `:338`(`unpartitionedNamesAndValuesBypassCacheWithoutTouchingSnapshotSeam`) | 去掉 values 那一行,其余不动 | + +**hudi** + +| 测试 | 怎么改 | +|---|---| +| `HudiConnectorPartitionListingTest.java:174-182`(`listPartitionValuesProjectsRequestedColumnOrder`) | 删除整个测试方法 | +| `HudiConnectorHmsCacheTest.java:80-91`(`partitionValuesTvfListsFresh`) | 删除该测试,并把类注释 `:38-45` 里「`partition_values()` 表函数 = `listPartitionValues`,必须取最新」这句改成据实描述:用户面枚举取最新的是 `listPartitionNames`(`SHOW PARTITIONS`),`partition_values()` 实际走 `listPartitions`(读缓存)。**注释要留下这个事实,不要一删了之**,否则下一个人会重新写回错的映射 | + +**maxcompute**:无测试引用 `listPartitionValues`(已全仓核实),只改主源与注释。 + +### 6.3 变异验证(确认新增/改写的断言真的能红) + +- 把宽 `createTable` 的抛出改回「构造一个 schema 后什么都不做」→ `fe-core` 那条改写后的断言必须变红。 +- 把四参 `dropDatabase` 的抛出去掉 → 新增的那条断言必须变红。 +- 把 paimon 的某个枚举钩子改成绕过共享缓存直接列举 → 改写后的缓存测试 `loadCount == 1` 必须变红。 + +### 6.4 端到端回归 + +本任务不改变任何用户可见行为(删掉的都是零调用方;`PRIMARY_KEYS_KEY` 本来写进去就被剥掉),**不需要新增 e2e**。建议在有集群的时机顺带跑一遍既有的分区枚举与建表用例确认零变化:`regression-test/suites/external_table_p0/hive/test_hive_partition_values_tvf.groovy`、`auth_p0/test_partition_values_tvf_auth.groovy`,以及 hive / iceberg / paimon 的建表与 `SHOW CREATE TABLE` 用例。e2e 本地跑不了,需要真集群。 + +## 七、风险与回退 + +- **回退**:单个 commit,纯删除 + 测试改写,`git revert` 即可完整回退,没有数据面或元数据面的残留。 +- **不涉及持久化与有线格式**:删掉的都是 FE 内部的接口方法与一个 FE 内部属性键(该键写入后就被 `fe-core` 剥掉,从不出现在 `SHOW CREATE TABLE`,也不下发 BE),与 Gson 持久化的类型标签、thrift 字段无关。 +- **主要风险是测试改写误伤意图**:`PRIMARY_KEYS_KEY` 在两个 `fe-core` 测试里是「保留键会被剥掉」的样本,必须换样本而不是删测试;paimon 的缓存测试必须保住 `loadCount == 1`。这两点在第六节已点名。 +- **次要风险是漏改**:由全反应堆含测试源的 `test-compile` 兜住(删除接口方法会让漏改处编译失败),这是删除类任务最可靠的信号。 +- **遗留的未用形参**:hive 最宽构造器的 `properties` 形参在删掉字段后不再被使用。选择保留是为了不动 31 处构造点;如果评审要求清掉,应作为独立改动做,不要塞进本任务。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` + - 第七章 7.1 节:死接口面为什么值得删(三种真实伤害)。 + - 第七章 7.3 节:本任务的四组条目(另两组——分片类型枚举族、推模型缓存失效接口——分别是 13 与 14 号任务)。 + - 第七章 7.4 节:为什么不走「先加过时标注、下个版本再删」,以及 `getPrimaryKeys` 属于「判断题不是事实题」的那一条——若最终决定保留主键接口,则**必须**同时补契约文档并让至少一个连接器真正消费它,不允许维持「零消费 + 零文档」的现状。 +- 相邻任务:11 号(第一批删除,建议先做以避开同文件冲突)、13 号、14 号(同为删死面)、24 号(连接器自声明属性的决策文档,与 2.1 节的「连接器级属性」命名问题相邻但不重叠)。 +- `plan-doc/connector-public-interface-cleanup/HANDOFF.md`:构建与验证的坑(maven build cache 静默跳过测试、绝对路径 `-f`、禁止 `git add -A` 等)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/13-delete-scan-range-type-enum.md b/plan-doc/connector-public-interface-cleanup/tasks/13-delete-scan-range-type-enum.md new file mode 100644 index 00000000000000..c1468a68b51b37 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/13-delete-scan-range-type-enum.md @@ -0,0 +1,191 @@ +> ⚠ **2026-07-25 实测订正,动手前必读**:`getRangeType()` **不是**零调用方。fe-core 确实从不读它, +> 但 `fe-connector-api` 自己的 `ConnectorScanRange.populateRangeParams` 默认实现读它,并把 +> `connector_scan_range_type=` 写进 `TTableFormatFileDesc` 的 jdbc 参数——这是 **BE 可见**的字符串; +> jdbc 是唯一不覆写 `populateRangeParams` 的连接器,所以这条默认路径今天是活的。 +> **本任务因此不是「删死代码」,而是一次会改变 jdbc 发给 BE 的内容的行为改动**,必须按行为改动配回归验证。 + +# 13. 删除分片类型枚举族(本轮最有价值的一条删除) + +> **优先级**:第三优先级(删除死接口面) | **风险**:中 | **前置依赖**:11 号(同样改动 `fe-connector-api` 的 scan 包,先做 11 号可避免同文件反复冲突;两者之间没有逻辑依赖,单独做本任务也能编译通过) +> **影响模块**:`fe-connector-api`、`fe-connector-es`、`fe-connector-hive`、`fe-connector-hudi`、`fe-connector-iceberg`、`fe-connector-jdbc`、`fe-connector-maxcompute`、`fe-connector-paimon`、`fe-connector-trino`、`fe-core`(**仅测试源**,只删不加) +> **预计改动规模**:约 22 个文件,净删约 130~150 行,新增约 25 行(一条新单测) +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +`ConnectorScanRange.getRangeType()` 是公共接口里**唯一一个「必须实现、却没有任何出口」的抽象方法**:8 个连接器全都实现了它、4 个测试匿名类也被迫实现一遍,而它的返回值在整个仓库里没有任何生产代码读取;本任务把这个方法、它的枚举 `ConnectorScanRangeType`、以及提供方一侧同义的 `ConnectorScanPlanProvider.getScanRangeType()` 一起删掉,让 `ConnectorScanRange` 的必须实现方法从 2 个降到 1 个。 + +## 二、背景:现在的代码是怎么写的 + +**枚举本体**:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRangeType.java:34`,4 个值 `FILE_SCAN` / `JDBC_SCAN` / `REMOTE_OLAP_SCAN` / `CUSTOM`。类注释(:20-32)声称: + +``` +Identifies the type of a ConnectorScanRange, which determines how BE processes the scan range. +Each type maps to a specific Thrift scan range variant in the execution layer. +``` + +**分片一侧的抽象方法**:`ConnectorScanRange.java:42-43` + +```java +/** Returns the scan range type, which determines BE processing. */ +ConnectorScanRangeType getRangeType(); +``` + +这是该接口仅有的两个抽象方法之一(另一个是 `getProperties()`,:112),其余十几个方法全部带默认实现。 + +**8 个连接器的实现,返回值完全一致**,无一例返回 `FILE_SCAN` 之外的值: + +| 连接器 | 位置 | 返回值 | +|---|---|---| +| es | `EsScanRange.java:75` | `FILE_SCAN` | +| hive | `HiveScanRange.java:82` | `FILE_SCAN` | +| hudi | `HudiScanRange.java:123` | `FILE_SCAN` | +| iceberg | `IcebergScanRange.java:154` | `FILE_SCAN` | +| jdbc | `JdbcScanRange.java:48` | `FILE_SCAN` | +| maxcompute | `MaxComputeScanRange.java:65` | `FILE_SCAN` | +| paimon | `PaimonScanRange.java:119` | `FILE_SCAN` | +| trino | `TrinoScanRange.java:79` | `FILE_SCAN` | + +也就是说 `JDBC_SCAN`、`REMOTE_OLAP_SCAN`、`CUSTOM` 三个值零生产者——**连 jdbc 连接器自己都返回 `FILE_SCAN`**。 + +**提供方一侧还有一个同义方法**:`ConnectorScanPlanProvider.java:52` 的 `getScanRangeType()`,带默认值 `FILE_SCAN`,javadoc 说「引擎用它决定生成哪种 Thrift 分片结构」。它有 3 个覆写(`HiveScanPlanProvider.java:117`、`EsScanPlanProvider.java:95`、`JdbcScanPlanProvider.java:61`),三处都是逐字返回默认值 `FILE_SCAN`;引擎侧没有任何调用点。 + +**唯一的运行时痕迹**在公共模块自己的默认方法里,`ConnectorScanRange.java:182-194`: + +```java +default void populateRangeParams(TTableFormatFileDesc formatDesc, TFileRangeDesc rangeDesc) { + Map props = new HashMap<>(getProperties()); + props.put("connector_scan_range_type", getRangeType().name()); // :185 + props.put("connector_file_format", getFileFormat()); // :186 + ... + formatDesc.setJdbcParams(props); +} +``` + +引擎的调用点只有一处:`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java:1796`,它先按 `getTableFormatType()` 设好 `table_format_type`,然后把 Thrift 结构的构造整体委派给分片自己的 `populateRangeParams`。 + +**谁真的走这个默认实现**:8 个分片类里有 7 个覆写了 `populateRangeParams`(es/hive/hudi/iceberg/maxcompute/paimon/trino),且没有一个调用 `super.populateRangeParams`;只有 `JdbcScanRange` 吃默认实现。所以 `connector_scan_range_type` 这个键在生产中只会出现在 jdbc 分片的 `jdbc_params` 里。 + +**这个键在 BE 侧零命中**:`be/` 全树 grep `connector_scan_range_type` 无任何结果;jdbc 的 JNI 侧读取器 `fe/be-java-extensions/jdbc-scanner/.../JdbcJniScanner.java:109-147` 是逐键 `params.getOrDefault("jdbc_url", …)` 这样按名取值的,多余的键被直接忽略。而 `jdbc_params` 这张表本身是活的(`be/src/exec/scan/file_scanner.cpp:1160`、`be/src/format_v2/jni/jdbc_reader.cpp:65` 都在消费它),所以**只能删这一个键,不能删整个 `populateRangeParams` 默认方法**。 + +**4 个测试匿名类被迫实现它**(这是「交税」最直观的证据):`fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeExplainStatsTest.java:58`、`fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitPartitionValuesTest.java:46`、`fe/fe-core/src/test/java/org/apache/doris/datasource/split/PluginDrivenSplitWeightTest.java:47`、`fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanRangeWeightDefaultsTest.java:38`。三个 fe-core 测试的注释直接写着「the two required methods」「the only two getters under test」——它们要测的是分片权重、分区值、EXPLAIN 统计,跟分片类型毫无关系。 + +## 三、为什么这是个问题 + +1. **必须实现,却没有出口。** 公共接口的抽象方法是对所有实现者的强制要求,代价必须由「引擎真的会读它」来偿付。这一处没有。新增一个连接器时,作者必须为它写一个 `return FILE_SCAN;`,而这个返回值走完全程后落在一个 BE 不认识的字符串键上。 +2. **文档是错的,会制造真实的排查浪费。** 枚举注释说「每个值对应一种 Thrift 分片结构」、`getScanRangeType()` 的注释说「引擎据此决定生成哪种结构」。实际决定 Thrift 形状的是两件别的事:分片自己覆写的 `populateRangeParams`(构造 iceberg/hudi/paimon/es 各自的 typed 结构),以及扫描节点级的格式类型(`PluginDrivenScanNode.getFileFormatType()`,:576-582,取自扫描节点属性表的 `file_format_type` 键)。按注释行事的人会去改枚举,然后发现改了不生效。 +3. **枚举值里带数据源名。** `JDBC_SCAN` 这种命名把具体数据源写进了本应中立的公共枚举,与「公共模块保持连接器中立」的方向相反;而它连一个生产者都没有。 +4. **它还在污染测试的表达。** 现有测试为了满足编译,被迫写出 `FILE_SCAN` 相关断言与注释,其中 `IcebergScanRangeTest.java:56-58`、`IcebergScanPlanProviderTest.java:174-175` 的 WHY 注释把错误的因果(「返回 JDBC_SCAN 会导致错误的 thrift 分片结构」)当作既定事实固化进了测试,反过来给后来人背书。 + +**用户可见后果**:没有。这不是正确性缺陷,唯一的运行时变化是 jdbc 分片的 `jdbc_params` 少一个没人读的键。 + +## 四、用一个最小例子说明 + +假设我要新增一个连接器 X,它从远端 OLAP 系统读数据,我看到枚举里正好有 `REMOTE_OLAP_SCAN`: + +| 我写了什么 | 现在实际发生什么 | 应该发生什么 | +|---|---|---| +| `getRangeType()` 返回 `REMOTE_OLAP_SCAN`,期待引擎生成 OLAP 形状的 Thrift 分片 | 没有任何事发生:引擎从不读这个方法。分片形状仍由我是否覆写 `populateRangeParams` 决定;这个值最终只会(在我不覆写 `populateRangeParams` 时)以 `connector_scan_range_type=REMOTE_OLAP_SCAN` 落进 `jdbc_params`,BE 不认识这个键 | 根本不该有这个方法可写。我要控制 Thrift 形状,就覆写 `populateRangeParams`;要控制 BE 读取器,就用 `getTableFormatType()` 和扫描节点属性里的格式类型 | +| `getRangeType()` 返回 `FILE_SCAN`(照抄别人) | 同样什么都不发生 | 同上 | +| 我写测试想覆盖「分片权重默认值」这件事,必须先给匿名类补一个 `getRangeType()` | 每个测试匿名类都得写一遍这段与被测行为无关的代码 | 匿名类只需实现 `getProperties()` | + +三行的差别只有一处:删掉之后,**这个选择项不存在了**,没人会再花时间去选它、也没人会再因为「我改了枚举为什么不生效」去 debug。 + +## 五、解决方案 + +### 5.1 目标状态 + +`ConnectorScanRange` 只剩一个抽象方法: + +```java +public interface ConnectorScanRange extends Serializable { + /** Returns additional connector-specific properties. */ + Map getProperties(); + // …其余全部带默认实现,包括 populateRangeParams +} +``` + +`populateRangeParams` 的默认实现去掉分片类型键(其余不动): + +```java +default void populateRangeParams(TTableFormatFileDesc formatDesc, TFileRangeDesc rangeDesc) { + Map props = new HashMap<>(getProperties()); + props.put("connector_file_format", getFileFormat()); + // partition.* 键照旧 + formatDesc.setJdbcParams(props); +} +``` + +`ConnectorScanPlanProvider` 不再有 `getScanRangeType()`;`ConnectorScanRangeType.java` 整个文件删除。 + +类注释同步据实改写:`ConnectorScanRange` 的类 javadoc(:33-35)现在说「range type 决定引擎如何转换成 Thrift 结构」,改成「连接器通过覆写 `populateRangeParams` 决定自己的 Thrift 形状,`getTableFormatType()` 决定 BE 侧读取器」。 + +### 5.2 改动清单 + +| 文件 | 位置 | 做什么 | +|---|---|---| +| `fe-connector-api/.../scan/ConnectorScanRangeType.java` | 整个文件 | 删除 | +| `fe-connector-api/.../scan/ConnectorScanRange.java` | :42-43 | 删除抽象方法与其注释 | +| 同上 | :33-35 | 类 javadoc 改写(去掉「range type 决定 Thrift 转换」的错误因果,改述为 `populateRangeParams` + `getTableFormatType()`) | +| 同上 | :63-65 | `getFileFormat()` 的 javadoc 引用了 `ConnectorScanRangeType#FILE_SCAN`,改成不依赖枚举的措辞(**只改这句引用,不动方法本身**) | +| 同上 | :185 | 删除 `props.put("connector_scan_range_type", …)` 一行;:186 起的其余内容保持原样 | +| `fe-connector-api/.../scan/ConnectorScanPlanProvider.java` | :43-55 | 删除 `getScanRangeType()` 及其 javadoc(javadoc 从 :43 起,方法体到 :55) | +| `fe-connector-es/.../EsScanRange.java` | :74-77 | 删除覆写 + `import` | +| `fe-connector-hive/.../HiveScanRange.java` | :81-84 | 同上 | +| `fe-connector-hudi/.../HudiScanRange.java` | :122-125 | 同上 | +| `fe-connector-iceberg/.../IcebergScanRange.java` | :153-156 | 同上 | +| `fe-connector-jdbc/.../JdbcScanRange.java` | :47-50 | 同上 | +| `fe-connector-maxcompute/.../MaxComputeScanRange.java` | :64-67 | 同上 | +| `fe-connector-paimon/.../PaimonScanRange.java` | :118-121 | 同上 | +| `fe-connector-trino/.../TrinoScanRange.java` | :78-81 | 同上 | +| `fe-connector-hive/.../HiveScanPlanProvider.java` | :116-119 | 删除 `getScanRangeType()` 覆写 + `import` | +| `fe-connector-es/.../EsScanPlanProvider.java` | :94-97 | 同上 | +| `fe-connector-jdbc/.../JdbcScanPlanProvider.java` | :60-63 | 同上 | +| `fe-connector-api/src/test/.../ConnectorScanRangeWeightDefaultsTest.java` | :37-40 | 删除匿名类里的 `getRangeType()` 覆写 + `import` | +| `fe-core/src/test/.../scan/PluginDrivenScanNodeExplainStatsTest.java` | :57-60 | 同上(fe-core 只删不加) | +| `fe-core/src/test/.../split/PluginDrivenSplitPartitionValuesTest.java` | :45-48 | 同上 | +| `fe-core/src/test/.../split/PluginDrivenSplitWeightTest.java` | :46-49 | 同上 | +| `fe-connector-iceberg/src/test/.../IcebergScanRangeTest.java` | :56-58 | 删掉该断言与它上面两行 WHY 注释;测试方法其余断言(path/start/length/fileSize/fileFormat/tableFormatType)全部保留 | +| `fe-connector-iceberg/src/test/.../IcebergScanPlanProviderTest.java` | :170-176 | 删除整个 `getScanRangeTypeIsFileScan` 测试方法 | +| `fe-connector-es/src/test/.../EsNodeInfoAndScanRangeTest.java` | :134-138 | 删除整个 `testScanRangeType` 测试方法 | +| `fe-connector-jdbc/src/test/.../JdbcScanRangeAndPropertiesTest.java` | :76-80 | 删除整个 `testScanRangeType` 测试方法;**同一个文件里新增六(1)要求的默认 `populateRangeParams` 测试** | + +### 5.3 明确不要顺手做的事 + +- **不要把 `getProperties()` 降成带默认实现(返回空表)。** 删掉分片类型方法后,`getProperties()` 成了唯一的抽象方法,看上去很想顺手一起默认化。实测收益很小:8 个连接器里只有 iceberg 返回空表(`IcebergScanRange.java:316-320`,它的载荷是 typed 字段),另外 7 个都有真实内容;只有 iceberg 加 4 个测试匿名类能因此少写几行。代价是明确的:`JdbcScanRange` 是唯一走默认 `populateRangeParams` 的生产实现,它的整张属性表就是 BE jdbc 读取器的入参,一旦 `getProperties()` 可以不实现,将来某个连接器忘了实现就会静默地把空表发给 BE(表现为运行期缺 `jdbc_url` 之类,而不是编译期报错)。这一项如果要做,应当作为独立决策,不要塞进本任务。 +- **不要动 `getFileFormat()` 本身**,也不要删 `connector_file_format` 键。本任务只改它 javadoc 里对被删枚举的引用。这个方法的出口是否充足是另一条独立结论(见调研报告附录 C.3 第 1 条 —— 格式与读取机制混在一个字段),牵动扫描级格式类型这条已知风险线,不适合在一次删除里带过。 +- **不要删 `populateRangeParams` 默认方法**,也不要删 `formatDesc.setJdbcParams(props)`。`jdbc_params` 在 BE 侧是活链路。 +- **不要顺手改 `getTableFormatType()` 的默认值 `"plugin_driven"`**,那是 BE 读取器路由的活值。 +- **不要去改 `plan-doc/` 下的历史文档**里对这个枚举的描述(`plan-doc/tasks/designs/` 里的两份设计稿、以及同日的另一份评审文档)。历史文档的勘误由 25 号任务统一处理。 +- **不要为「不许再出现分片类型枚举」加 shell 或正则构建门禁。** 删除后类不存在,编译本身就是最强门禁。 + +## 六、怎么验证 + +1. **新增一条单测钉住唯一的运行时变化**(放 `fe-connector-jdbc` 的 `JdbcScanRangeAndPropertiesTest`,因为 jdbc 是默认 `populateRangeParams` 的唯一生产消费者,而目前**整个仓库没有任何测试覆盖这个默认实现**): + - 构造一个带 `querySql/jdbcUrl/jdbcUser` 的 `JdbcScanRange`,调用 `populateRangeParams(new TTableFormatFileDesc(), new TFileRangeDesc())`; + - 断言 `formatDesc.getJdbcParams()` 仍然包含 `query_sql`、`jdbc_url`、`jdbc_user` 这几个 BE 侧真实消费的键(WHY:这张表就是 BE jdbc 读取器的入参,删键的改动绝不能碰到它); + - 断言这张表**不含** `connector_scan_range_type` 键(WHY:这个键 BE 与 JNI 侧都不读,本次删除的意图就是让它消失;变异验证:把那行 `props.put` 加回去 → 该断言变红)。 +2. **零残留 grep**(存在性检查,可直接跑): + `grep -rn "ConnectorScanRangeType\|getRangeType\|getScanRangeType\|connector_scan_range_type" --include=*.java fe/` 应为 0 命中。 +3. **编译门禁(最强单一信号)**:全反应堆**含测试源**编译,禁用任何跳过测试编译的参数—— + `mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile` + 这一步同时覆盖了「删接口方法后所有实现类与匿名类都清理干净」和「`import` 未残留(checkstyle 扫测试源)」两件事。 +4. **跑受影响模块的单测**,必须显式关掉 maven build cache(否则 surefire 会被静默跳过、`BUILD SUCCESS` 是空的): + `mvn -f .../fe/pom.xml -Dmaven.build.cache.enabled=false -pl fe-connector/fe-connector-api,fe-connector/fe-connector-jdbc,fe-connector/fe-connector-es,fe-connector/fe-connector-iceberg test` + 另外单独跑 fe-core 的三个受影响测试类(`PluginDrivenSplitWeightTest`、`PluginDrivenSplitPartitionValuesTest`、`PluginDrivenScanNodeExplainStatsTest`)。 +5. **端到端回归**:不需要新增用例。这个改动唯一的有线格式变化是 jdbc 分片的 `jdbc_params` 少一个键,已被上面的单测钉住;如果手上正好有环境,跑一遍任意 jdbc 目录的查询回归即可(jdbc 是唯一走默认路径的连接器),其余连接器的分片路径字节不变。 + +## 七、风险与回退 + +- **风险点只有一处**:`populateRangeParams` 默认实现里的键删除。若误删同一段里的 `connector_file_format` 或 `setJdbcParams` 调用,会打断 jdbc 的活链路,表现为 jdbc 目录查询在 BE 侧拿不到连接参数而失败。六(1)的断言正是为此设置。 +- **不涉及 Gson 持久化**:`ConnectorScanRangeType` 没有注册进任何 `RuntimeTypeAdapterFactory`,也不在任何元数据镜像里;`ConnectorScanRange` 虽然声明 `extends Serializable`,但仓库里没有任何地方对它做 Java 序列化(`fe-core` 的 datasource 包下无 `ObjectOutputStream` / `SerializationUtils` 命中),分片对象只在单次查询的规划期内存活。因此删方法不存在兼容性负担。 +- **不涉及 Thrift 有线结构**:删除的只是一个字符串键,没有改动任何 `.thrift` 定义。 +- **插件是独立打包的**:连接器与公共模块必须同批构建、同批部署。混用(老连接器包 + 新公共模块)会在类加载期报 `NoSuchMethodError` / `NoClassDefFoundError`。本仓库的连接器与 `fe-core` 一起构建发布,正常流程下不会出现混用;但如果有人手工替换单个插件 zip,需要重新打包全部连接器。 +- **回退**:本任务是纯删除 + 一条新测试,`git revert` 单个提交即可完整回到原状,无数据面残留。 + +## 八、相关背景 + +- 调研报告 `plan-doc/connector-public-interface-cleanup/audit-report.md`:附录 A.3 第 49、50、51 条 —— 分片类型枚举与强制方法零消费者,其中第 50、51 条的「复核收窄」记录了严重度为中而非高的理由,以及「BE 侧零命中」的证据;附录 C.3 第 1 条 —— `getFileFormat()` 默认值把格式与读取机制混在一起,是独立结论(本任务明确不动)。 +- 同目录 `README.md` 第三优先级小节说明了这一批删除的判据:「死接口的成本不是占空间,是逼着每个新连接器为不存在的出口交税」。 +- 11 号任务(第一批死接口面删除)同样改动 `fe-connector-api` 的 scan 包,建议排在其后。 +- 关于扫描级格式类型为什么危险(本任务刻意不碰 `getFileFormat()` 的原因):`PluginDrivenScanNode.getFileFormatType()`(`:576-582`)在分片之前就决定了 BE 走哪一代文件读取器,发错值会把整个连接器钉在旧读取器上。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/14-delete-push-model-cache-invalidation.md b/plan-doc/connector-public-interface-cleanup/tasks/14-delete-push-model-cache-invalidation.md new file mode 100644 index 00000000000000..619df66c8ebfe9 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/14-delete-push-model-cache-invalidation.md @@ -0,0 +1,170 @@ +# 14. 删除推模型的缓存失效接口,让「失效」只剩一个方向 + +> **优先级**:第三优先级(删死面) | **风险**:低 | **前置依赖**:无硬前置;与《补齐上下文包装类的转发缺口》(本任务集编号 06)改同两个文件,需约定先后,见 5.3 +> **影响模块**:`fe-connector-spi`、`fe-core`、`fe-connector-iceberg`、`fe-connector-paimon` +> **预计改动规模**:删 3 个文件(约 246 行)+ 改 7 个文件(约 60 行),净减约 280 行,无新增代码 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +「丢弃元数据缓存」这件事现在有两套方向相反的接口:引擎通知连接器的那一套是活的,连接器通知引擎的那一套(`ConnectorMetaInvalidator`)没有任何连接器调用、而且引擎侧根本履行不了它承诺的语义——把后者整套删掉,让失效只剩「引擎 → 连接器」一个方向。 + +## 二、背景:现在的代码是怎么写的 + +**方向一(活的):引擎通知连接器丢弃连接器自己的缓存。** +定义在 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java`:`invalidateTable`(312 行)、`invalidateAll`(316 行)、`invalidateDb`(324 行)、`invalidatePartition`(336 行)。引擎侧实测 **17 处**调用(调研报告里写的 16 处漏了一处跨行书写的调用),分布在三个文件: + +| 调用方文件 | 处数 | +|---|---| +| `fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java` | 10(289、464、562、616、642、686、687、766、779、792 行) | +| `fe/fe-core/src/main/java/org/apache/doris/catalog/RefreshManager.java` | 5(124、202、203、248、288 行) | +| `fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogMgr.java` | 2(818、853 行) | + +这一套的分区参数是**分区名**。`Connector.java:333-334` 的契约写得很明确:`canonical partition names ("col=val/.../colN=valN")`;连接器侧有对应测试,`fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveConnectorPartitionViewCacheTest.java:124` 传的就是 `"dt=2024-01-01"`。 + +**方向二(死的):连接器通知引擎丢弃引擎的缓存。** +`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java:32` 定义了一个 5 方法接口(`invalidateAll` / `invalidateDatabase` / `invalidateTable` / `invalidatePartition` / `invalidateStatistics`,全是空 default,并带一个 `NOOP` 常量)。入口是 `fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java:109` 的 `getMetaInvalidator()`,默认返回 `NOOP`。引擎侧实现是 `fe/fe-core/src/main/java/org/apache/doris/connector/ExternalMetaCacheInvalidator.java:34`,由 `fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java:168-171` 返回。 + +这一套的分区参数是**分区列值**(`ConnectorMetaInvalidator.java:48-50` 的注释:`["2024", "01"]`)。 + +**实测调用方情况**:全仓库提到 `MetaInvalidator` 的只有 9 个文件——接口自身、`ConnectorContext`、`DefaultConnectorContext`、fe-core 的桥实现 `ExternalMetaCacheInvalidator`、fe-core 的 `FakeConnectorPluginTest`(断言默认返回 `NOOP`)、iceberg/paimon 两个上下文包装类里的一行转发、iceberg 的测试替身 `RecordingConnectorContext`、以及 `IcebergProcedureOpsTest:241` 注释里的一次提及。**零个连接器生产代码调用它。** + +**真正在跑的是拉模型。** 外部元存储的变更由引擎轮询:`fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java:164` 调连接器的 `pollOnce` 拿一批中立的变更描述,`applyDescriptors`(202 行)由引擎自己作用到对象图和缓存上;需要丢连接器缓存时再走上面方向一的 17 处调用。连接器只负责「取事件 + 解析」,不负责通知谁去丢缓存。 + +iceberg 的测试已经把这段历史钉住了。`fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java:56-61` 的类注释写:失效是引擎的责任,dispatch 不得失效任何缓存,`ctx.invalidatedTables` 在每次分派后都必须是空的,「非空就意味着**已被移除的**连接器侧通知又被加回来了」;对应断言有 7 处(167、242、271、290、324、349、370 行)。 + +## 三、为什么这是个问题 + +**第一,引擎侧履行不了这个接口承诺的语义**,两个方法名不副实,且这一点是写在代码注释里的既知事实: + +- `ExternalMetaCacheInvalidator.java:60-69`:SPI 传来的是分区**值**,而引擎的分区缓存按分区**名**索引,从值还原名需要分区列名而 SPI 没有携带 —— 于是降级成整表失效,注释自认 `correct but over-broad`。 +- `ExternalMetaCacheInvalidator.java:71-77`:`invalidateStatistics` 是**空方法**,因为引擎没有「只丢统计不丢 schema」的入口(行数缓存按 id 而非名索引),调 `invalidateTable` 会违反接口注释里「without dropping schema cache」的承诺。 + +也就是说:5 个方法里有 1 个是骗人的空操作、1 个的作用域比声明的粗一整个数量级。这两处的行为还各被一个单测钉住(`ExternalMetaCacheInvalidatorTest.java:76` 与 `:91`),于是「已知做不到」被固化成了「受保护的既定行为」。 + +**第二,同一件事的两套词汇互相冲突,是给下一个连接器作者埋的坑。** 名字撞、参数语义还相反: + +| | 引擎 → 连接器(活的) | 连接器 → 引擎(死的) | +|---|---|---| +| 按库失效 | `Connector.invalidateDb(dbName)` | `ConnectorMetaInvalidator.invalidateDatabase(dbName)` | +| 按分区失效的第三个参数 | 分区**名**列表 `["dt=2024-01-01"]` | 分区**值**列表 `["2024", "01"]` | + +一个新连接器作者看到 `ConnectorContext` 上挂着 `getMetaInvalidator()`,很自然会以为「我发现远端变了就该调它」,然后写出一段编译通过、运行不报错、但按分区失效实际把整张表的缓存都掀掉、按统计失效什么都不做的代码。 + +**第三,这是公共接口上的死面积。** `ConnectorContext` 是每个连接器都要面对的引擎服务门面(今天 19 个方法),其中一个方法通向一个完全没人用的方向。删掉它对任何在跑的功能都是零影响。 + +用户能不能观察到错误行为?今天不能——因为没人调用。这不是正确性缺陷,是「留着就会变成正确性缺陷」的陷阱。 + +## 四、用一个最小例子说明 + +场景:有人在远端 Hive 元存储上执行 + +```sql +ALTER TABLE sales.orders ADD PARTITION (year='2024', month='01'); +``` + +Doris 侧需要让缓存反映这个变化。同一个需求,两条路的实际结果: + +| 连接器想表达的意思 | 走死掉的推模型今天实际发生什么 | 走活的拉模型(保留的那一套)实际发生什么 | +|---|---|---| +| 「`sales.orders` 多了一个分区 `year=2024/month=01`,只丢这个分区」 | 连接器调 `context.getMetaInvalidator().invalidatePartition("sales", "orders", ["2024","01"])` → 引擎拿到的是列值、缓存按分区名索引 → **整张表的缓存全丢** | 引擎轮询到变更后自己调 `connector.invalidatePartition("sales", "orders", ["year=2024/month=01"])` → **按分区名精确失效** | +| 「这张表的统计过期了,只丢统计、别丢 schema」 | 连接器调 `invalidateStatistics("sales","orders")` → **什么都不发生**(空方法),连接器却以为已经生效 | 这条路今天不存在;需要时走正常的刷新路径 | + +删掉左边一列,「失效」就只剩右边一套词汇:分区一律用分区名,方向一律是引擎调连接器。 + +## 五、解决方案 + +### 5.1 目标状态 + +- `fe-connector-spi` 里不再有 `ConnectorMetaInvalidator` 这个类型。 +- `ConnectorContext` 的方法数从 19 降到 18,不再有 `getMetaInvalidator()`: + + ```java + // 删除下面整块(含其上方 6 行 javadoc) + // default ConnectorMetaInvalidator getMetaInvalidator() { + // return ConnectorMetaInvalidator.NOOP; + // } + ``` + +- `fe-core` 少一个类 `ExternalMetaCacheInvalidator` 与它的单测(fe-core 只减不增,符合当前阶段纪律)。 +- 失效相关的公共接口只剩 `Connector` 上那 4 个方法(签名不动): + + ```java + default void invalidateTable(String dbName, String tableName) { } + default void invalidateAll() { } + default void invalidateDb(String dbName) { } + default void invalidatePartition(String dbName, String tableName, List partitionNames) { } + ``` + +### 5.2 改动清单 + +| 文件 | 动作 | +|---|---| +| `fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorMetaInvalidator.java` | **删除整个文件**(57 行) | +| `fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java` | 删除 102-111 行(6 行 javadoc + `getMetaInvalidator()` default 方法) | +| `fe/fe-core/src/main/java/org/apache/doris/connector/ExternalMetaCacheInvalidator.java` | **删除整个文件**(82 行) | +| `fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java` | 删除 168-171 行的覆写 + 33 行的 import(这是 `ExternalMetaCacheInvalidator` 在生产代码里唯一的构造点) | +| `fe/fe-core/src/test/java/org/apache/doris/connector/ExternalMetaCacheInvalidatorTest.java` | **删除整个文件**(107 行,5 个 `@Test`) | +| `fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java` | 删除 63-76 行的 `contextMetaInvalidatorDefaultsToNoop` + 28 行的 import(`Collections` 的 import 仍被其它测试用到,别删) | +| `fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java` | 删除 143-146 行的转发覆写 + 24 行的 import | +| `fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.java` | 删除 121-124 行的转发覆写 + 24 行的 import | +| `fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/RecordingConnectorContext.java` | 删除 79-80 行的 `invalidatedTables` 字段(含其上方注释)+ 82-90 行的覆写 + 23 行的 import(`ArrayList` / `List` 的 import 还有别的字段在用,别删) | +| `fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergProcedureOpsTest.java` | 删除 7 处 `ctx.invalidatedTables` 断言(167、242、271、290、324、349、370 行)及其上方解释注释;**保留** 56-61 行类注释里「失效由引擎负责、分派后由引擎走标准刷新路径」这段设计说明,只删掉提到 `ctx.invalidatedTables` 这个已消失机制的句子 | + +关于最后一条的取舍:这 7 处断言原本证明的是「连接器没有走推模型通知引擎」。删掉这套 SPI 之后,连接器**在类型层面就不存在**这条通道了,断言的失败条件不可能再出现——留一个永远不会红的断言比删掉它更糟(不可能失败的测试没有意义)。它保护的意图改由「代码里没有这个接口」结构性保证,设计意图则留在类注释里。 + +### 5.3 明确不要顺手做的事 + +1. **不要把 `Connector.invalidateDb` 改名成 `invalidateDatabase`。** 推模型删掉后名字冲突自动消失,剩下的单套名字叫什么已经不重要;改名要动 17 处调用点,纯搅动,且属于另一个「命名统一」议题。 +2. **不要动 `ExternalMetaCacheMgr`。** 它的 `invalidateCatalog` / `invalidateDb` / `invalidateTable` 都另有引擎自身的调用方(例如 `fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java:690` 与 `.../ExternalDatabase.java:131`),删掉桥不会留下孤儿方法,也就不需要连带清理。 +3. **不要顺手补「只丢统计不丢 schema」的入口。** 那是一个独立的功能缺口,而且要往 fe-core 加数据源相关代码,违反当前阶段 fe-core 只出不进的纪律。谁真需要它,另开任务。 +4. **不要顺手给 iceberg/paimon 的包装类补别的缺失转发。** 实测 paimon 的包装类少转发 `newStorageUriNormalizer` 与 `getFileSystem`,这是编号 06 那项任务的范围。 +5. **不要顺手给 `fe-connector-api` / `fe-connector-spi` 写模块边界文档。** 那是同一章调研里的另一条建议,与本任务解耦。 +6. **不要在 iceberg 测试里保留「改写版」断言去模拟推模型**(比如自己造一个记录器再断言它是空的)。那是给已删除的机制立纪念碑。 + +**与编号 06 的顺序**:两项都改 iceberg/paimon 的 `TcclPinningConnectorContext.java`,但改的是不同方法块,文本不相邻。建议**先做本任务**(纯删,让包装类需要转发的方法先少一个),再做 06 补齐剩余转发;若已先做了 06,本任务照删本方法块即可,不需要回滚 06 的改动。合批也可以,但要在同一个提交里说明两件事。 + +## 六、怎么验证 + +1. **编译门禁(本任务最强的单一信号)**:删类型的验证本质上是符号级验证——任何漏改的引用都编译失败。跑全反应堆、**含测试源**: + + ```bash + mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T1C test-compile + ``` + + 禁止使用跳过测试编译的参数。要求 `BUILD SUCCESS`。 + +2. **删净反查**: + + ```bash + grep -rn "MetaInvalidator" /mnt/disk1/yy/git/wt-catalog-spi --include=*.java + ``` + + 期望零命中(`plan-doc/` 下的历史记录文档命中属正常,不要去改历史记录)。 + +3. **受影响单测(必须禁用 maven build cache,否则 surefire 会被静默跳过、`BUILD SUCCESS` 是空的)**: + - `FakeConnectorPluginTest`(fe-core,删掉一个 `@Test`,其余必须仍全绿) + - iceberg 的 `IcebergProcedureOpsTest`、`TcclPinningConnectorContextTest` + - paimon 的 `TcclPinningConnectorContextTest` + +4. **确认活的那一套没被牵连**:`HiveConnectorPartitionViewCacheTest.invalidatePartitionDropsTheWholeTablesCachedView`(`HiveConnectorPartitionViewCacheTest.java:105`)必须仍绿——它验证的是保留下来的方向(引擎调连接器、参数是分区名)。 + +5. **不需要变异验证,也不需要端到端回归**:删的是零生产调用方的接口面,运行时行为不变;没有任何 SQL 路径的行为会改变,因此不新增 groovy 回归。checkstyle 会扫测试源,注意删 import 后不要留下未使用 import。 + +## 七、风险与回退 + +风险低。三条可能的意外与应对: + +- **担心「以后 HMS 事件管线搬进连接器时还需要它」**:不成立。事件管线已经按拉模型落地(`MetastoreEventSyncDriver` + 连接器的 `pollOnce` + 中立变更描述),而且 iceberg 测试注释明确记载连接器侧通知是**被有意移除**的。真需要推模型时,那时的需求会带着「分区名 vs 分区值」「统计缓存入口」这些今天缺失的信息一起来,重新设计比留一个错的空壳更好。早期计划文档(`plan-doc/00-connector-migration-master-plan.md`、`plan-doc/01-spi-extensions-rfc.md`、`plan-doc/decisions-log.md` 的相关决策条目)里还写着「事件管线通过这个接口回调」,那是已被实现推翻的旧决策;本任务落地后应在这些文档里补一句作废说明,但**不要**改写历史进度记录。 +- **担心外部实现者**:这是内部接口,仓库外没有实现者;不做过时标注、直接删(与本轮整治的既定节奏一致)。 +- **回退**:改动全在一个提交内且是纯删除,`git revert` 即可完整恢复,无数据/持久化影响(这些类型不参与 Gson 持久化、也不参与 thrift 有线格式)。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` + - 第十三节「`api` 与 `spi` 两个模块的边界说不清」——该节末尾「缓存失效这件事被这个边界切成了两半,而且方向相反」那段给出了两套方向的对照,同节建议第 2 条就是删掉这套死的推模型; + - 第 7.3 节「需要连带改连接器的删除」——本条在表格里,连带改动写的是「删 iceberg / paimon 两个包装类的转发与相关测试替身」; + - 附录 A 第 78、79 两条原始发现——失效有两套并存机制、且两套方向相反的词汇,其中 78 被复核收窄为「构成零生产调用方的死 SPI 表面」; + - 附录 C.1「两轮独立结论高度重叠」——在「本文更准的地方」清单里确认结论是「应删而不是改名」。 +- 相关任务:编号 06《补齐上下文包装类的转发缺口》(改同两个文件,顺序见 5.3)。 +- 旧决策出处(本任务作废其结论):`plan-doc/decisions-log.md` 的「HMS event pipeline 放 fe-connector-hms,通过 ConnectorMetaInvalidator 回调」条目、`plan-doc/01-spi-extensions-rfc.md` 第 6 节。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/15-remove-catalog-type-allowlist.md b/plan-doc/connector-public-interface-cleanup/tasks/15-remove-catalog-type-allowlist.md new file mode 100644 index 00000000000000..20e4ca1abd3962 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/15-remove-catalog-type-allowlist.md @@ -0,0 +1,277 @@ +# 15. 删除目录类型白名单,让注册了插件的连接器真的能被路由到 + +> **优先级**:第四优先级(兑现承诺) | **风险**:中 | **前置依赖**:无(本任务不依赖前面任何一号任务;它只改目录创建这条路径,与公共接口的删除批次没有文件重叠) +> **影响模块**:`fe-connector-spi`(加一个带默认实现的方法)、`fe-connector-hudi`(覆写它)、`fe-core`(删白名单 + 改路由顺序 + 加重名冲突检测)、`fe-connector-hive`(仅改两行过时注释) +> **预计改动规模**:6 个生产文件,净删约 15 行、新增约 60 行;新增/改动测试约 3 个文件、约 120 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +引擎在决定「一个 `CREATE CATALOG` 要不要去问连接器插件」时,先查一张写死在 `fe-core` 里的七个类型名的集合;不在这张集合里的类型,哪怕插件已经装好、已经被插件加载器成功装配、启动日志里都能看到它,`CREATE CATALOG` 依然会失败。本任务删掉这张集合,把它承担的唯一一件真实职责(排除 hudi)上移成连接器自己的一句声明,让「注册了插件就能被路由到」这句承诺在代码上第一次成立。 + +## 二、背景:现在的代码是怎么写的 + +### 白名单本体 + +`fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java:56-57`: + +```java +private static final Set SPI_READY_TYPES = + ImmutableSet.of("jdbc", "es", "trino-connector", "max_compute", "paimon", "iceberg", "hms"); +``` + +它上面有一段 9 行的注释(`:47-55`),说明了两件事:这七个类型走插件(SPI)路径,别的类型掉到下面的内建 `switch`;以及**「不要把 hudi 加进这个集合」**。 + +### 判定点 + +同文件 `createCatalog`(`:76`)里,`catalogType` 从 `type` 属性(或 resource)解析出来后(`:79-96`),有三段判定: + +```java +Connector spiConnector = null; +if (SPI_READY_TYPES.contains(catalogType)) { // :110 第一次查集合 + spiConnector = ConnectorFactory.createConnector( + catalogType, props, new DefaultConnectorContext(name, catalogId)); +} +if (spiConnector != null) { // :114 + catalog = new PluginDrivenExternalCatalog(...); // :117 +} else if (SPI_READY_TYPES.contains(catalogType)) { // :119 第二次查集合 + if (isReplay) { + catalog = new PluginDrivenExternalCatalog(..., null); // :128 降级注册 + } else { + throw new DdlException("No connector plugin loaded for catalog type '" + ...); // :131 + } +} +if (catalog == null) { // :138 内建类型兜底 + switch (catalogType) { + case "lakesoul": throw new DdlException("Lakesoul catalog is no longer supported"); // :143 + case "doris": ... // :146 + case "test": ... // :153(仅单测) + default: throw new DdlException("Unknown catalog type: " + catalogType); // :157 + } +} +``` + +所以现在的形状是:**先查集合 → 集合里才问插件 → 集合里但没插件就(建目录时)报错或(重放时)降级 → 集合外一律落到内建 `switch`,最后抛「Unknown catalog type」**。 + +### 插件侧的自描述发现机制其实已经完备 + +`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java`: + +- `:46` `String getType()` —— 连接器自报类型名,注释写着「对应 `CREATE CATALOG` 里的 `type` 属性」。 +- `:52-54` `default boolean supports(String catalogType, Map properties)` —— 默认按类型名比较,但**留了按属性判定的口子**。 + +`fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java:126-144` 的 `createConnector` 就是照这个机制做的:遍历已注册 provider,第一个 `supports(...)` 返回 true 的胜出,检查 API 版本后建连接器;一个都不匹配就返回 `null`。也就是说**动态分派早就有了,白名单是叠在它上面的一层编译期硬门禁**。 + +仓库里一共 8 个 provider(各连接器模块的 `META-INF/services/org.apache.doris.connector.spi.ConnectorProvider` 都已核实):`hms`、`iceberg`、`paimon`、`jdbc`、`es`、`max_compute`、`trino-connector`、`hudi`。白名单正好等于「这 8 个减去 hudi」。**换句话说,白名单今天的全部效果就是两件事:排除 hudi,以及挡住所有第三方连接器。** + +### hudi 为什么必须被排除 + +`fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorProvider.java:31-39` 的类注释已经把理由写透了:`"hudi"` 是一个**只用于兄弟连接器查找的类型串,不是用户可见的目录类型**。一张 hudi 表寄生在 Hive 元存储目录上,运行时由 hms 网关通过 `ConnectorContext.createSiblingConnector("hudi", ...)` 构造成内嵌兄弟连接器;`fe-core` 没有、也不该有 hudi 的目录类。真给 `type=hudi` 建一个独立目录,就会造出一个没有引擎侧目录语义支撑的空壳。 + +兄弟连接器走的是另一条门:`fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java:174-183` 的 `createSiblingConnector` 直接调 `ConnectorFactory.createConnector(...)`,**根本不经过 `CatalogFactory` 的白名单**。这条门必须保持能查到 hudi。 + +### 类型名唯一性只写在文档里,没人保证 + +`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScopes.java:39-43` 在论证「语句级作用域的命名空间跨连接器不会撞」时,明确把 `getType()` 当成前提: + +> the connector's connector-type name (its `ConnectorProvider.getType()` …) Because `getType()` is a connector's unique identity, source-prefixing makes these namespaces distinct across connectors *by construction* + +但注册路径并不检查这件事: + +- `ConnectorPluginManager.loadBuiltins()`(`:74-80`,类路径 ServiceLoader 批次)**完全不去重**,同名类型两个 provider 会一起进 `providers` 列表,谁胜出由插入顺序决定。 +- `loadPlugins()`(`:88-112`,生产用的插件目录批次)依赖 `DirectoryPluginRuntimeManager`:`fe/fe-extension-loader/src/main/java/org/apache/doris/extension/loader/DirectoryPluginRuntimeManager.java:131-139` 按 `factory.name()`(对 `ConnectorProvider` 默认就是 `getType()`,见 `ConnectorProvider.java:84-86` 与 `DirectoryPluginRuntimeManager.java:257-259`)判重,重名的那个被跳过并记一条加载失败,由 `ConnectorPluginManager.java:100-104` 打成 WARN —— **不抛异常,第一个胜出**。 +- 两个批次**之间**没有任何去重:一个插件目录里的 provider 和一个类路径上的 provider 声明同一个类型名,双方都会进列表。 + +## 三、为什么这是个问题 + +**第一,它让「新增连接器不需要修改公共模块」这句承诺在代码上是假的。** 一个第三方连接器把 `ConnectorProvider` 写对、`META-INF/services` 注册对、插件目录放对,FE 启动日志里 `ConnectorPluginManager initialized ... registered types: [..., acme-lake]` 都打出来了,用户执行 `CREATE CATALOG ... "type" = "acme-lake"` 依然得到「Unknown catalog type: acme-lake」。要让它能用,唯一办法是改 `CatalogFactory.java:57` 那一行、重新编译并发布整个 FE —— 这恰好是插件化想消掉的事情。这是整轮整治里唯一能真正兑现这条承诺的改动。 + +**第二,它让 `supports(catalogType, properties)` 这个能力事实上不可用。** 按属性(而不是仅按类型名)分派的口子留在了公共接口上,但因为外层先按类型名过一遍白名单,任何「类型名不在名单里、只有属性能识别」的分派都不可能发生。目前全仓 0 个连接器覆写 `supports` —— 不是因为没人需要,是因为覆写了也不会被调用。删掉白名单,这个能力才第一次真正接通。 + +**第三,它顺带造成一个现存缺陷:不认识的目录类型会让 FE 起不来。**(这是本任务顺带修掉的净改善) +`CatalogMgr.replayCreateCatalog`(`fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogMgr.java:546-549`)在编辑日志重放时调 `CatalogFactory.createFromLog`。一旦它抛异常,`fe/fe-core/src/main/java/org/apache/doris/persist/EditLog.java:1150-1154` 的 `OP_CREATE_CATALOG` 分支把异常交给 `:1524-1538` 的兜底 `catch`,那里除非该操作码被列进 `Config.skip_operation_types_on_replay_exception`(`fe/fe-common/src/main/java/org/apache/doris/common/Config.java:1311`,默认 `{-1, -1}` 即空),就直接 `System.exit(-1)`。 + +现在的代码只对**白名单内**的类型做了重放降级保护(`:119-129`)。白名单外的类型在重放时会走到 `:157` 抛异常 → FE 进程退出。用户观察到的现象是:**一次本来只该让某个目录不可用的问题,变成整个 FE 起不来**,而且日志里只有一行「Unknown catalog type」。 + +顺带说明:从元数据镜像恢复的那条路径**已经**没有这个问题 —— 镜像里目录是 Gson 反序列化成 `PluginDrivenExternalCatalog`(`fe/fe-core/src/main/java/org/apache/doris/persist/gson/GsonUtils.java:358-407`),连接器是首次访问时由 `PluginDrivenExternalCatalog.createConnectorFromProperties()`(`:192-205`)惰性创建的,那里**不查白名单**。所以本任务的另一个价值是把两条恢复路径的口径拉齐:第三方连接器的目录一旦能建出来,重启后无需任何 Gson 改动就能正确恢复(`clazz` 就是 `PluginDrivenExternalCatalog`)。 + +## 四、用一个最小例子说明 + +假设我要新增一个连接器 `acme-lake`,插件已装好并被成功加载。 + +```sql +CREATE CATALOG my_lake PROPERTIES ("type" = "acme-lake", "acme.uri" = "http://acme:8080"); +``` + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +|---|---|---| +| 上面这条 `CREATE CATALOG` | 报错 `Unknown catalog type: acme-lake`。引擎连插件都没问 —— `"acme-lake"` 不在 `CatalogFactory` 那七个字符串里,直接掉进内建 `switch` 的 `default` | 引擎向已注册的 provider 逐个问 `supports("acme-lake", props)`,`AcmeConnectorProvider` 认领,建出 `PluginDrivenExternalCatalog` | +| 同一条语句,但插件**没**装 | 报错 `Unknown catalog type: acme-lake`(看不出是插件缺失还是类型写错) | 报错「没有插件认领类型 `acme-lake`,当前已注册类型:\[hms, iceberg, ...\]」 | +| 目录已建好,此后重启 FE,且这条建目录日志还没被 checkpoint 掉,而插件被运维误删 | 重放抛异常 → `System.exit(-1)`,**FE 起不来** | 降级注册该目录;只有真去访问它时才报「未找到插件,请确认插件已安装」 | +| `CREATE CATALOG h PROPERTIES ("type" = "hudi")` | 报错(白名单里没有 hudi)—— 这是**正确**行为,必须保住 | 依然报错。理由不再是「不在白名单里」,而是 hudi 的 provider 自己声明了 `isStandaloneCatalogType() == false` | + +「我今天必须动哪些文件」这个问题的答案就是这张表的第一行:**`fe/fe-core/.../CatalogFactory.java` 一行,然后重编译发布 FE**。本任务之后答案变成:一个文件都不用动。 + +## 五、解决方案 + +### 5.1 目标状态 + +**(1)连接器自己声明「我能不能作为一个独立目录出现」**,在 `fe-connector-spi` 的 `ConnectorProvider` 上加一个带默认实现的方法(默认 true,对齐仓库既有的 `supportsXxx()` opt-in 惯例;这里语义是「默认允许、少数否认」,所以默认值取 true): + +```java +/** + * 本 provider 的类型能否作为一个独立目录出现在 CREATE CATALOG 的 type 属性里。 + * + * 返回 false 表示这个连接器只以内嵌兄弟身份存在(由另一个连接器通过 + * ConnectorContext.createSiblingConnector 构造并持有),引擎不会为它建独立目录; + * 它仍然正常参与服务发现与兄弟查找。默认 true。 + */ +default boolean isStandaloneCatalogType() { + return true; +} +``` + +**(2)区分两条查询入口。** 兄弟连接器查找**必须**仍然能查到非独立类型,所以这个开关只能作用在「建独立目录」这条路径上,绝不能塞进 `ConnectorPluginManager.createConnector`: + +| 入口 | 用途 | 是否过滤非独立类型 | +|---|---|---| +| `ConnectorPluginManager.createConnector` / `ConnectorFactory.createConnector` | 兄弟连接器查找(`DefaultConnectorContext.createSiblingConnector`) | **否**(hudi 靠它) | +| 新增 `createStandaloneCatalogConnector`(两个类上各一个同名入口) | 建独立目录 | 是 | + +实现上让两个入口共用一个私有方法、只差一个 `standaloneOnly` 布尔量即可,不要复制遍历逻辑。 + +**(3)`CatalogFactory.createCatalog` 改成三段式**,删掉 `SPI_READY_TYPES`: + +``` +① 无条件问插件:createStandaloneCatalogConnector(catalogType, props, ctx) + 命中 -> PluginDrivenExternalCatalog(带连接器) +② 没命中 -> 问引擎内建类型(lakesoul / doris / test 这个 switch 原样保留) + 命中 -> 原样处理 +③ 都没命中: + isReplay == true -> 降级注册 PluginDrivenExternalCatalog(connector = null),打 WARN + isReplay == false -> DdlException,文案说明「没有插件认领该类型」并列出 ConnectorFactory.getRegisteredTypes() +``` + +第 ③ 步就是白名单内那段降级逻辑(`:119-135`)的去白名单版本:**降级/报错的判据从「类型在名单里」变成「引擎也不认识它」**。降级目录首次访问时的报错文案不用改,`PluginDrivenExternalCatalog.initLocalObjectsImpl` 已有(`:162-165`):`No ConnectorProvider found for plugin-driven catalog: , type: . Ensure the connector plugin is installed.` + +**关于顺序**:插件优先于内建类型,是为了保持现有七个类型的行为逐字不变(它们今天就是插件优先)。代价是一个插件若声明 `getType()` 为 `doris` / `test` / `lakesoul` 会遮蔽内建类型 —— 这一点写进 `getType()` 的契约文档(见下),并由第(4)条的冲突检测兜住其中的插件对插件冲突。 + +**(4)注册时检测类型名冲突。** 在 `ConnectorPluginManager` 里维护一个「已被认领的类型名」集合(小写比较),两个加载批次都过这一关: + +- `loadBuiltins()`:撞名直接抛 `IllegalStateException`。类路径上出现两个同名类型是构建期错误,不是部署事故,应该当场炸。 +- `loadPlugins()`:撞名**跳过后来者**并打 `LOG.error`(把两个 provider 的类名都打出来)。这里保持该方法既有的「部分成功」契约 —— 一个坏插件目录不该阻止 FE 启动。 +- `registerProvider(provider)`(`:177-179`,测试用的最高优先级插队)**不参与去重**,它就是为了遮蔽而存在的,多个测试依赖它。 + +同时把类型名唯一性契约写进 `ConnectorProvider.getType()` 的 javadoc:全局唯一(不区分大小写)、不得与引擎内建类型名相同、并且是语句级作用域命名空间前缀的锚点(呼应 `ConnectorStatementScopes.java:39-43` 已经写下的那段论证)。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java` | 在 `getType()`(`:46`)之后加 `isStandaloneCatalogType()` 默认方法;补强 `getType()` 的 javadoc(唯一性契约 + 不得撞内建类型名 + 命名空间前缀锚点) | +| `fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorProvider.java` | 覆写 `isStandaloneCatalogType()` 返回 `false`;把类注释(`:31-39`)与 `getType()` 里那两行注释(`:45-46`)中「NEVER add "hudi" to `SPI_READY_TYPES`」改写成指向这个覆写 —— 白名单已不存在,留着会把后人指向一个不存在的符号 | +| `fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorPluginManager.java` | 加 `createStandaloneCatalogConnector`(与 `createConnector` 共用私有遍历,差一个布尔量);加类型名冲突检测(见 5.1 第 4 条);`registerProvider` 不动 | +| `fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorFactory.java` | 加对应的静态入口 `createStandaloneCatalogConnector`(照 `:66-75` 的 `createConnector` 写法,插件管理器为 null 时返回 `null`) | +| `fe/fe-core/src/main/java/org/apache/doris/datasource/CatalogFactory.java` | 删 `SPI_READY_TYPES`(`:56-57`)与它上面那段注释(`:47-55`);`:107-135` 改成 5.1 第(3)条的三段式;`:140-142` 那条提到 `SPI_READY_TYPES` 的注释改写;`:157` 的 `default` 分支不再是唯一出口,改由第 ③ 步统一处理 | +| `fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalCatalog.java` | `createConnectorFromProperties()`(`:204`)改用 `createStandaloneCatalogConnector`。这是建独立目录的**第二道门**(镜像恢复后的惰性创建),两道门口径一致才不会留下歧义 | +| `fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveConnector.java` | `:73` 与 `:78` 两条注释提到 `CatalogFactory.SPI_READY_TYPES`,改成指向新机制。仅注释 | + +### 5.3 明确不要顺手做的事 + +- **不要动 `lakesoul` 那条硬失败**(`CatalogFactory.java:143`)。它在重放时同样会让 FE 退出,是一个**相邻但独立**的缺陷;它属于「有意下线某类型」的语义,改它等于改用户可见的下线策略,应该单独立项讨论。本任务只保证「引擎不认识的类型」在重放时降级。 +- **不要把 `isStandaloneCatalogType` 铺到 `validateProperties` 上**(`ConnectorPluginManager.java:161-174` / `ConnectorFactory.java:97-103`)。它只被 `PluginDrivenExternalCatalog.checkProperties`(`:212`)调用,而非独立类型永远建不出这样的目录,加过滤没有可达的行为差异,只增加要维护的分支。 +- **不要顺手清理仓库里其它二十来处提到 `SPI_READY_TYPES` 的注释**。除了 5.2 表里点名的那几处(它们承载「不要给 hudi 建独立目录」这条真实契约、必须跟着改),其余大多是各连接器里叙述迁移历史的文字(例如 iceberg 那批「切换前是惰性的」说明),跟着一起改会把这个补丁摊成几十个文件、淹掉真正的改动。 +- **不要顺手把 `supports` 的按属性分派用起来**。本任务只负责把这条路接通,不负责给任何连接器写第一个覆写。 +- **不要给类型名唯一性加 shell/正则构建门禁**。类型名是方法返回值,判定它需要理解 Java 语义;本仓库已有结论:那类门禁只适合存在性与前缀类不变量。这里用运行时的注册冲突检测 + 单测就够了。 +- **不要顺手改 `Env.changeCatalog` 里那句按 `"es"` 硬编码的默认库设置**(`fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java:6509-6512`)。它是另一处按数据源名分支,属于别的任务。 + +## 六、怎么验证 + +**(1)单元测试:兄弟查找与独立目录必须分道扬镳**(扩 `fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorPluginManagerTest.java`) + +- 注册一个 `isStandaloneCatalogType()` 返回 false 的假 provider:`createStandaloneCatalogConnector(它的类型)` 必须返回 `null`,而 `createConnector(同一类型)` 必须返回非 `null`。**断言要写清 WHY**:后者是 hms 网关构造 hudi 兄弟连接器的唯一通路,前者返回非 null 就等于允许建出一个没有引擎侧目录语义的空壳目录。 +- 注册一个类型名是任意第三方串(例如 `"acme-lake"`)的普通假 provider:`createStandaloneCatalogConnector` 必须命中。这条就是「删掉白名单」的行为断言 —— 它在改动前必然失败(因为那时压根没有这个方法),改动后必须通过。 + +**(2)单元测试:注册冲突**(同一文件) + +- 两个 provider 声明同一个类型名(大小写不同也算撞):类路径批次抛 `IllegalStateException`;插件目录批次跳过后来者且 `getRegisteredTypes()` 里该类型只出现一次。 +- 为了能直接测到,建议把「登记一个已发现的 provider」抽成一个包内可见的小方法,让两个批次都调它,测试直接打这个方法 —— 不要为了测试去伪造 `META-INF/services` 文件。 +- 一条断言保住 `registerProvider` 的遮蔽语义:先 `loadPlugins` 装一个 `"iceberg"`,再 `registerProvider` 一个同名的,后者必须胜出且不报冲突(多个既有测试依赖这条,见 `DefaultConnectorContextSiblingTest.java:77`)。 + +**(3)单元测试:重放不再让 FE 退出**(扩 `fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogTest.java`,那里已有现成的 `registerCatalogViaReplay` 私有助手,`:169-177`) + +- 用一个引擎和插件都不认识的类型(例如 `"acme-lake"`)走 `mgr.replayCreateCatalog`:**必须不抛异常**,且目录能在 `mgr` 里查到。断言注释里要写清 WHY:这条路径上抛异常会经由 `EditLog` 的兜底 `catch` 走到 `System.exit(-1)`,代价是整个 FE 起不来。 +- 同一个目录上触发一次会走到 `makeSureInitialized` 的访问,断言报错文案含 `No ConnectorProvider found for plugin-driven catalog`。 +- 反向断言:非重放路径(`CREATE CATALOG`)对同一个类型必须报错,且文案里能看到已注册类型列表。**这条不能漏** —— 否则「删白名单」会退化成「任何拼错的 type 都静默建出一个坏目录」。 + +**(4)不要破坏的既有测试。** `ExternalCatalogTest.testShowCreateCatalogMasksSensitiveProperties`(`:126-167`)刻意用「重放降级」这条路注册一个 `type=iceberg` 的目录(fe-core 单测里加载不到 iceberg 插件)。改动后它走的是新的第 ③ 步而不是原来的白名单分支,行为必须完全一样:目录注册成功、`SHOW CREATE CATALOG` 能打出被脱敏的属性。跑测试时**必须禁用 maven build cache**,否则 surefire 会被静默跳过、`BUILD SUCCESS` 是空的。 + +**(5)编译门禁。** 全反应堆**含测试源**的 `test-compile`(禁用任何跳过测试编译的参数)。这是本任务最强的单一信号:`SPI_READY_TYPES` 是私有字段,只有注释引用它,所以编译不会替你发现漏改的注释 —— 但它会替你发现所有漏改的调用点。maven 用绝对路径的 `-f`。 + +**(6)端到端回归。** 需要跑一轮既有的外部目录建目录用例(hms / iceberg / paimon / jdbc / es 各至少一个 `CREATE CATALOG` + 一次查询),确认这七个类型的建目录行为逐字不变。**必须专门跑一次 hudi 读取用例**(hudi 表寄生在 hms 目录上),确认兄弟连接器构造这条路没有被新开关误伤 —— 这是本任务最需要端到端兜底的一点,单测只能证明路由,证不了整条读取链。 + +**(7)不需要变异验证。** 本任务的核心断言(非独立类型在两个入口上一个通一个不通)本身就是双向的,改动前后行为差异明确。 + +## 七、风险与回退 + +| 风险 | 说明与对策 | +|---|---| +| 新开关塞错位置,把 hudi 的兄弟查找也挡掉 | 这是本任务**唯一的高危错误**:hudi 表会整体读不出来,而且 fe-core 单测未必能发现。对策是 5.1 第(2)条那张表(开关只作用于建独立目录的入口)+ 第(1)(6)条的双向断言与 hudi 端到端用例 | +| 打错字的 `type` 从「明确报错」变成「静默建坏目录」 | 只可能发生在实现漏掉第 ③ 步的非重放分支时。第(3)条的反向断言专门守这个 | +| 第三方插件遮蔽内建类型名(`doris` / `test` / `lakesoul`) | 插件优先的顺序带来的固有代价,换取现有七个类型行为不变。已写进 `getType()` 契约文档;插件之间的撞名由注册冲突检测挡住 | +| 重放语义从抛异常改成降级注册 | 这是本任务有意为之的净改善。已核实全仓(含 `regression-test/`)没有任何测试断言 `Unknown catalog type` 或 `No connector plugin loaded` 这两条文案,没有测试依赖旧行为 | +| Gson 持久化兼容 | **无影响**。第三方目录持久化为 `PluginDrivenExternalCatalog`,`GsonUtils.java:362-363` 早已注册该子类型;本任务不新增、不删除、不重命名任何持久化类型标签 | + +**回退**:改动集中在 6 个文件、彼此无跨阶段耦合,`git revert` 单个提交即可完整回退。`isStandaloneCatalogType()` 是带默认实现的新增方法,回退它不会让任何连接器编译失败(唯一的覆写在 hudi,随同一提交一起回退)。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` + - 第 4.1 节(1)「目录类型白名单——最该删的一处」:本任务的直接来源,包含 `isStandaloneCatalogType` 的原始草案。 + - 附录 A.1 第 1 条与第 7 条:同一问题的两次独立记录(一条按可扩展性归类、一条按路由归类),复核结论均为「部分成立」。 + - 落地批次表里的「5. 删类型白名单」一行,以及排期建议中「这是唯一能真正兑现『新增连接器不需要修改公共模块』的一批;在它合入之前,这个承诺在代码上是假的」。 +- `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorStatementScopes.java:22-51`:类型名唯一性契约的下游消费者,本任务第(4)条冲突检测要保护的正是它的前提。 +- `fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorProvider.java:25-39`:hudi 为什么没有独立目录概念,写得比本文更细,改注释前先读它。 +- 相邻但不在本任务范围内的同类问题(都在审计报告里各有条目):`CreateTableInfo` 里建表能力的四处协同硬编码、`PluginDrivenExternalTable` 里两份重复的 engine 展示名 `switch`、`FileQueryScanNode` 的文件缓存准入类型白名单。它们和本任务是同一个病根的不同发作点,但各自独立可做。 + +--- + +## 九、落地记录(2026-07-25,两个提交) + +**提交**:`[refactor](catalog) remove the catalog type allow-list so a registered connector is reachable`(生产改动 + 测试)、`[doc](catalog) stop pointing at the deleted catalog type allow-list`(纯注释)。 + +### 动手前按符号复核的结论 + +任务文档的核心事实全部成立:8 个 provider(`hms` `iceberg` `paimon` `jdbc` `es` `max_compute` `trino-connector` `hudi`)、白名单 = 8 减 hudi、兄弟查找不经白名单、插件目录批次已按名字判重而类路径批次与跨批次都不判重、全仓(含 `regression-test/`)无任何测试断言 `Unknown catalog type` 或 `No connector plugin loaded` 两条文案。 + +**六处需要修正或补充**: + +1. **文案变化面比文档写的大。** 不只是「引擎不认识的类型」——原白名单内 7 个类型在**插件缺失**时的报错文案也变了(旧文案专门提到 `connector_plugin_root`)。已核实无测试依赖,写进了提交信息。 +2. **删字段留下 20 处悬空注释**(分布 15 个文件),文档只点了必须改的 4 处。已按第二个提交处理。其中 **6 处在本轮之前就已经是错的**(iceberg 那批仍写着「iceberg 还没进白名单、所以代码不可达」,有一处甚至说「没有 iceberg 分片到达 BE」),这些直接删除而不是改写。 +3. **删字段后 `ImmutableSet` / `Set` 两个 import 会孤立**,`test-compile` 不报、`checkstyle` 报。本轮因为保留了 `BUILTIN_CATALOG_TYPES` 仍在用,未触发。 +4. **三段式不需要新增数据结构**:把原 `switch` 的 `default` 分支改写成第三段即可,文档描述得像要再维护一个集合。 +5. **`CREATE CATALOG` 是目录级 CREATE 权限、不是管理员权限**,所以「报错列出已注册类型」确实是对非管理员暴露插件清单。已拍板列出(对齐 Trino 同类报错),并**过滤掉非独立类型**——把一个建不出来的名字列给用户会误导。 +6. **文档把「插件可遮蔽内建类型名」当固有代价接受,实际有更好解法**:Trino 的做法是单一注册表 + 重名直接拒绝。本轮借了后半段——`doris` / `test` / `lakesoul` 成为保留字,在**注册期**拒绝,于是「遮蔽」这个风险不存在,路由顺序也不再影响正确性。 + +### 与文档方案的差异 + +- 新增 `CatalogFactory.BUILTIN_CATALOG_TYPES`(包内可见)+ `isBuiltinCatalogType()`,`ConnectorPluginManager` 用它做保留字判定。这是文档没有的一项。 +- 类型名检查统一到一个包内可见的 `registerDiscovered(provider, failFast)`,两个加载批次都过它(文档建议如此),顺带覆盖了文档未提的「空白类型名」与「跨批次重名」。 +- `registerProvider`(测试插队)不参与检查,注释写清了原因。 + +### 验证结果 + +- 全反应堆**含测试源** `test-compile` + `checkstyle` 通过(两个提交各跑一次)。 +- 52 个单测通过:`ConnectorPluginManagerTest`(13,新增 8)、`CatalogFactoryPluginRoutingTest`(5,新建)、`HudiConnectorProviderTest`(1,新建)、`ExternalCatalogTest`(3,含刻意走重放降级那条)、`DefaultConnectorContextSiblingTest` / `StoragePropsTest`(各 3)、`PluginDrivenExternalTableEngineTest`(16)、`CreateTableInfoEngineCatalogTest`(9)。 +- **做了三次变异验证**(文档原说不需要,但把开关放错入口是本任务唯一高危错误,值得实证): + 1. 把独立过滤搬到兄弟查找入口(两个入口对调)→ **两个方向同时变红**:兄弟查找返回 null,且非独立类型能建出目录。 + 2. 把 hudi 的声明改成 `true` → `HudiConnectorProviderTest` 变红。 + 3. 删掉保留字检查 → `providerClaimingAnEngineBuiltinCatalogTypeIsRefused` 变红。 +- **未执行**:端到端(需真集群)。7 个类型各一次建目录+查询、以及**一次 hudi 读取**仍待有集群时跑——单测只能证明路由,证不了整条读取链。 + +### 本轮踩到的坑(供后续批次复用) + +- **`-pl <单模块>` 会从本地仓库解析兄弟模块的旧 jar**。给 `fe-connector-spi` 加了方法后,用 `-pl fe-connector/fe-connector-hudi` 跑测试会报「method does not override」——那是旧 jar,不是代码错。跑连接器模块的测试一律走全反应堆 + `-Dtest=` 过滤。 +- **checkstyle 的方法名正则是 `^[a-z][a-z0-9][a-zA-Z0-9_]*$`**:第二个字符也必须小写,`aTypeThatIsCreatable` 这种测试名会红。 +- **`PluginDrivenExternalCatalog.getConnector()` 会触发 `makeSureInitialized()`**,纯单测里用不了(需要真 Env)。判断「目录是插件建出来的还是降级注册的」改用「假 provider 记录自己被问了几次」,而且这样断言的正是「引擎真的问了插件」这个不变量。 +- **`ConnectorMetadata` 的冻结基线没有被牵动**:本轮加的方法在 `ConnectorProvider`(`fe-connector-spi`),不在那份基线的覆盖范围内,无需重新生成。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/16-replace-source-name-branches.md b/plan-doc/connector-public-interface-cleanup/tasks/16-replace-source-name-branches.md new file mode 100644 index 00000000000000..9882c6d50aa7aa --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/16-replace-source-name-branches.md @@ -0,0 +1,262 @@ +# 16. 把引擎里三处按数据源名判定的软阻塞分支改成中立声明 + +> **优先级**:第四优先级(兑现承诺) | **风险**:中 | **前置依赖**:无 +> **影响模块**:`fe-connector-spi`、`fe-connector-api`、`fe-core`、`fe-connector-hive`、`fe-connector-iceberg`、`fe-connector-paimon`、`fe-connector-hudi`、`fe-connector-es` +> **预计改动规模**:改约 13 个文件;新增约 60 行(3 个默认方法 + 1 个查表工具 + 各连接器一行声明),删约 20 行(源名白名单、两个源专有剖析常量及其三处引用),新增 3~4 个单测约 150 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +引擎里还有三处用「数据源类型名字符串」当判据的分支(事件同步的强制预热、BE 文件缓存准入治理的适用范围、切换目录时自动进入的默认库),名单外的连接器静默拿不到这份行为;把这三处换成连接器自己声明的中立开关,再顺手删掉查询剖析里两个源专有、且从来没人赋值的常量。改完之后,第 9 个、第 10 个连接器都不必再碰这四处公共代码。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 事件同步的一次性强制预热按 `"hms"` 筛选 + +`fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java` 是元数据变更事件同步的引擎侧驱动。它每个周期遍历所有目录(`realRun`,99 行起),对每个插件目录做两件事: + +- 若目录**已初始化**:直接用中立能力探针取事件源(132 行 `pluginCatalog.getConnector().getEventSource()`,137 行判 `null` 跳过)。这一段完全中立,没有任何类型判断。 +- 若目录**尚未初始化**(107 行 `!pluginCatalog.isInitialized()`):为了对齐迁移前的行为——旧的事件轮询器每周期强制初始化每一个 HMS 目录,好让「从未被查询过」的目录也能播下事件游标——这里补了一次性的强制预热,而这次预热被一个硬编码类型串挡住: + +```java +// MetastoreEventSyncDriver.java:119 +if (!"hms".equalsIgnoreCase(pluginCatalog.getType())) { + continue; +} +try { + pluginCatalog.makeSureInitialized(); +``` + +`getType()` 读的是目录属性、不会触发初始化,所以这里刻意用类型串而不是碰连接器实例(108~118 行的注释把这一点写清楚了:不能让空闲的 paimon/iceberg/jdbc 目录被这段代码强制初始化)。 + +同时 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java` 的 `getEventSource()`(347 行)在文档里承诺(340~346 行): + +> the engine's single, connector-agnostic, role-aware event driver iterates catalogs and calls `pollOnce` only on connectors that expose a source, **never via `instanceof`** + +### 2.2 BE 文件缓存准入治理按目录类型白名单 + +`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java`: + +```java +// :117-121 +// The data cache function only works for queries on Hive, Iceberg, Hudi(via HMS), and Paimon tables. +private static final Set CACHEABLE_CATALOGS = new HashSet<>( + Arrays.asList("hms", "iceberg", "paimon") +); +``` + +唯一消费点在 `fileCacheAdmissionCheck()`(747 行起): + +```java +// :757 +if (CACHEABLE_CATALOGS.contains(externalTableIf.getCatalog().getType())) { + ... FileCacheAdmissionManager.getInstance().isAdmittedAtTableLevel(...) +} else { + // LOG.debug("Skip file cache admission control for non-cacheable table: ...") +} +``` + +该方法在 `FileQueryScanNode` 的扫描范围构建里被调用(398 行,前置条件是会话开了 `enableFileCache` 且 `Config.enable_file_cache_admission_control` 打开),基类 `fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java:773` 的默认实现恒返回 `true`。 + +已核实 `FileQueryScanNode` 在主代码里只有三个子类:`PluginDrivenScanNode`、`TVFScanNode`、`RemoteDorisScanNode`。也就是说白名单里的 hms/iceberg/paimon 三种目录今天**全部**由 `PluginDrivenScanNode` 服务,fe-core 里已经没有各数据源自己的文件扫描节点了。 + +### 2.3 切换目录时的默认库硬编码 `"es"` + +`fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java` 的 `changeCatalog`(6495 行起)在恢复「上次待过的库」之后,补了一段: + +```java +// :6509-6512 +if ("es".equalsIgnoreCase( + (String) catalogIf.getProperties().get(CatalogMgr.CATALOG_TYPE_PROP))) { + ctx.setDatabase("default_db"); +} +``` + +而 `"default_db"` 这个名字在连接器侧已经有权威定义:`fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnectorMetadata.java:43` 的 `public static final String DEFAULT_DB = "default_db"`。同一个事实在引擎和连接器各写了一份。 + +(补充核实:ES 目录今天已经是插件目录——`fe/fe-core/src/main/java/org/apache/doris/persist/gson/GsonUtils.java:366` 把持久化别名 `"EsExternalCatalog"` 映射到 `PluginDrivenExternalCatalog`,fe-core 里已无 `EsExternalCatalog` 类,所以「按类型查 provider」这条路对它是通的。) + +### 2.4 查询剖析里两个源专有常量 + +`fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java` 有两个常量: + +```java +// :158-159 +public static final String ICEBERG_SCAN_METRICS = "Iceberg Scan Metrics"; +public static final String PAIMON_SCAN_METRICS = "Paimon Scan Metrics"; +``` + +它们出现在两张表里:显示顺序表 `EXECUTION_SUMMARY_KEYS`(218~219 行)和缩进表 `EXECUTION_SUMMARY_KEYS_INDENTATION`(278~279 行)。连接器侧各有一份逐字相同的字符串字面量,并在注释里声称必须与 fe-core 常量一致:`fe/fe-connector/fe-connector-iceberg/.../IcebergScanProfileReporter.java:52-53`、`fe/fe-connector/fe-connector-paimon/.../PaimonScanMetrics.java:47-48`。 + +真实机制已核实为:连接器交出的 `ConnectorScanProfile` 由 `PluginDrivenScanNode.writeScanProfilesInto`(420 行起)转写,分组名被用来 **get-or-create 一个子剖析节点**(427~428 行 `new RuntimeProfile(profile.getGroupName())` + `executionSummary.addChild(...)`),子节点的顺序就是插入顺序。而上面那两张表只作用于**信息字符串**:`SummaryProfile.init()`(520 行起)给 `EXECUTION_SUMMARY_KEYS` 里每个键无条件塞一条 `"N/A"`;缩进表只在 `RuntimeProfile.prettyPrint` 打印本节点自己的信息字符串时被查(409~410 行)。 + +## 三、为什么这是个问题 + +**第一处(事件同步预热)——文档承诺与代码不符,且有真实后果。** 调研报告把后果写成「事件源永远不会被激活」,实测要收窄:已初始化的目录走的是完全中立的能力探针,一旦目录被查询过一次就正常同步。准确的后果是:**在某个 FE 上从未被初始化过的目录,其事件游标不会被自动播种**。这在多 FE 部署里是真实的——每个 FE 各自跑一份驱动、各自维护游标,而 follower 上通常没人发查询,于是一个实现了 `getEventSource()` 的新连接器在 follower 上(以及 FE 重启后到首次查询之间)拿不到增量同步,只能等有人查它。这正是 108~118 行注释为 HMS 特意保留强制预热的原因,新连接器却拿不到同一份照顾。同时 `getEventSource()` 文档里那句「从不用 `instanceof`」在这条分支上是假的——按类型名筛选和 `instanceof` 是同一件事的两种写法。 + +**第二处(文件缓存准入)——目前不是 bug,是扩展点。** 今天的覆盖是正确的:白名单外的 jdbc / trino / max_compute 走 JNI 读取,本来就没有 BE 文件缓存,跳过路径还有 `LOG.debug` 兜底。问题在判据错位:治理的真实前提是「这个连接器的数据由 BE 原生文件读取器读取,因此 BE 文件缓存对它有效」,代码却写成「目录类型叫这三个名字之一」。将来新增一个 BE 原生读文件的湖格式连接器,它的表会静默绕过缓存准入治理(用户设置了库/表级的缓存准入规则,对这个新目录不生效,且没有任何报错),必须改 fe-core 才能纳入。 + +**第三处(默认库)——SPI 缺一个声明位。** 「切到本目录时自动进入某个默认库」是数据源自己的事实(ES 没有库的概念,Doris 给它造了一个 `default_db`),却只能由引擎硬编码。新连接器要这个行为必须改 `Env`。 + +**第四处(剖析常量)——中立性瑕疵 + 每个查询两行垃圾。** `ICEBERG_SCAN_METRICS` / `PAIMON_SCAN_METRICS` 已核实**没有任何地方给它们赋值**(全仓 grep:只有常量声明、两张表、以及镜像断言的测试)。于是每个查询的执行摘要里都无条件多出两行 `- Iceberg Scan Metrics: N/A` 和 `- Paimon Scan Metrics: N/A`;而真正的 iceberg/paimon 扫描指标是以**同名子节点**的形式挂上去的,于是同一份剖析里会出现「一行 N/A 的条目」和「一个同名的子树」并存,反而更容易看错。缩进表里那两条对子节点完全无效(子节点的信息字符串键是各自的指标名,不在缩进表里)。连接器注释里「MUST equal fe-core 常量(display ordering)」的说法与实际机制不符:分组名是连接器自选的子节点名字,fe-core 不需要预先知道它。 + +## 四、用一个最小例子说明 + +假设我要新增一个连接器 `X`(一个 BE 原生读 Parquet 的新湖格式,带元数据变更事件源,并且它的元数据模型没有「库」这一层,希望切进去就落到一个固定库)。我今天必须动的公共模块文件: + +| 我想要的行为 | 今天实际发生什么 | 我今天必须改哪里 | 改完本任务后 | +|---|---|---|---| +| 目录的元数据变更能自动同步 | 我实现了 `getEventSource()`,master 上查过一次的目录能同步;follower 上(没人查)游标从不播种,一直不同步 | `MetastoreEventSyncDriver.java:119`,把 `"x"` 加进类型判断 | 在 `XConnectorProvider` 里 `providesEventSource()` 返回 `true` | +| 我的表纳入 BE 文件缓存准入治理 | 用户配的缓存准入规则对我的目录静默不生效,只有一行 `LOG.debug` | `FileQueryScanNode.java:119`,把 `"x"` 加进 `CACHEABLE_CATALOGS` | 在 `XScanPlanProvider` 里 `supportsFileCache()` 返回 `true` | +| `SWITCH x;` 之后自动进入 `default_db` | 停在没有库的状态,用户必须显式 `USE` | `Env.java:6509`,在 `"es"` 旁边加 `"x"` | 在 `XConnectorProvider` 里 `defaultDatabaseOnUse()` 返回库名 | +| 在查询剖析里输出我的扫描指标 | 实际上**已经可以**(分组名自选,无需 fe-core 登记);但 fe-core 的注释与常量表让人以为必须先去加常量 | 误以为要改 `SummaryProfile.java:158` | 什么都不用改(那两个常量已删) | + +用户视角的一个具体现象(第四处):今天任意一条查询 + +```sql +SET enable_profile = true; +SELECT count(*) FROM internal.some_db.some_olap_table; +``` + +的执行摘要里也会出现 + +``` +- Iceberg Scan Metrics: N/A +- Paimon Scan Metrics: N/A +``` + +——这条查询跟 iceberg / paimon 毫无关系。删掉这两个常量是修正,不是回归。 + +## 五、解决方案 + +### 5.1 目标状态 + +**`ConnectorProvider` 新增两个默认方法**(`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorProvider.java`)。挂在 provider 而不是 `Connector` 上是这条任务的关键判断:这两处判定发生在目录**可能尚未初始化**的时刻(事件驱动的预热分支、`SWITCH` 时可能还没碰过连接器),只能按类型查 provider;碰 `Connector` 实例会强制初始化,引入现状没有的副作用(正好是 `MetastoreEventSyncDriver` 108~118 行注释要避免的事)。 + +```java +/** + * 本类型的连接器是否会通过 Connector#getEventSource() 暴露增量元数据变更源。 + * 引擎用它决定:一个尚未初始化的目录是否值得为播种事件游标做一次性强制预热。 + * 必须与 Connector#getEventSource() 是否返回非 null 保持一致(同一份能力的两个高度)。 + * 默认 false —— 无事件源的连接器不会被强制初始化。 + */ +default boolean providesEventSource() { + return false; +} + +/** + * 切换到本类型目录时应自动进入的库名;空表示不自动进入任何库(默认)。 + * 用于元数据模型没有「库」这一层、由 Doris 造一个固定库名的数据源(如 ES 的 default_db)。 + */ +default Optional defaultDatabaseOnUse() { + return Optional.empty(); +} +``` + +**`ConnectorScanPlanProvider` 新增一个能力位**(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java`,紧邻已有的 `supportsBatchScan`(250 行)/ `supportsTableSample`(268 行)放,照抄它们的 opt-in 形状): + +```java +/** + * 本连接器规划出的扫描范围是否由 BE 的原生文件读取器读取(因此 BE 文件缓存对它有效, + * 引擎应对它的表施加文件缓存准入治理)。JNI 读取的连接器必须保持 false —— + * 它们不经过 BE 文件缓存,做准入判定只是白花一次远程/本地开销。默认 false。 + */ +default boolean supportsFileCache() { + return false; +} +``` + +**fe-core 侧多一个中立的 provider 查表入口**:`ConnectorFactory`(`fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorFactory.java`)加静态 `findProvider(String catalogType, Map properties)` 返回 `Optional`,委派给 `ConnectorPluginManager` 里一个新的同名方法——该方法复用 `createConnector`(127 行起)里现成的选择逻辑(第一个 `supports(...)` 为真且 `apiVersion` 匹配的 provider),只是不 `create`。plugin manager 未初始化时返回空。 + +关于「fe-core 只出不进」:这个查表入口是**中立**的(不含任何数据源名),它的存在是为了删掉三处数据源名判定;本任务在 fe-core 里的数据源相关代码是净减的。这不属于「为了让删除能编译过就把逻辑挪进 fe-core」。 + +**三处判定改写后的样子**: + +- `MetastoreEventSyncDriver.java:119` → `if (!providesEventSource(pluginCatalog.getType(), pluginCatalog.getProperties())) { continue; }`,其余(`makeSureInitialized()` 的 try/catch、`!isInitialized()` 的一次性守卫)一字不动。 +- `FileQueryScanNode.java:757` → `if (isFileCacheAdmissionApplicable()) { ... }`;`FileQueryScanNode` 里加 `protected boolean isFileCacheAdmissionApplicable() { return false; }`;`PluginDrivenScanNode` 覆写为「取 `resolveScanProvider()`(245 行),非 null 则返回 `supportsFileCache()`」。删掉 `CACHEABLE_CATALOGS` 常量与随之失效的 `java.util.Arrays` import(81 行;`HashSet` 在 455 行仍有用,别删)。 +- `Env.java:6509-6512` → 查 provider 的 `defaultDatabaseOnUse()`,非空则 `ctx.setDatabase(...)`。**保持原有位置与覆盖语义**:仍在「恢复上次待过的库」之后执行,即声明了默认库的目录会覆盖 `lastDb`。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe/fe-connector/fe-connector-spi/.../ConnectorProvider.java` | 新增 `providesEventSource()`、`defaultDatabaseOnUse()` 两个默认方法(见 5.1 签名草案) | +| `fe/fe-connector/fe-connector-api/.../scan/ConnectorScanPlanProvider.java` | 新增 `supportsFileCache()` 默认方法,紧邻 `supportsTableSample` | +| `fe/fe-core/.../connector/ConnectorPluginManager.java` | 新增 `findProvider(String, Map)`,复用 `createConnector` 的 provider 选择逻辑(含 `apiVersion` 校验),不创建连接器 | +| `fe/fe-core/.../connector/ConnectorFactory.java` | 新增静态 `findProvider`,manager 未初始化时返回空 | +| `fe/fe-core/.../datasource/MetastoreEventSyncDriver.java` | 119 行的 `"hms"` 判定换成 provider 声明;把 108~118 行注释里「Mirror that ONLY for the event-source type ("hms", ...)」改成按声明筛选的表述,保留「按类型查 provider、不碰连接器实例,避免强制初始化」的理由 | +| `fe/fe-core/.../datasource/scan/FileQueryScanNode.java` | 删 `CACHEABLE_CATALOGS`(117~121 行)与 `Arrays` import(81 行);757 行改为调用新的 `protected boolean isFileCacheAdmissionApplicable()`(默认 false,带注释说明默认 false 保持 TVF / 远程 Doris 扫描节点的现状) | +| `fe/fe-core/.../datasource/scan/PluginDrivenScanNode.java` | 覆写 `isFileCacheAdmissionApplicable()`,委派给 `resolveScanProvider().supportsFileCache()`(provider 为 null 时 false) | +| `fe/fe-core/.../catalog/Env.java` | `changeCatalog` 里 6509~6512 行的 `"es"` 判定换成 provider 的 `defaultDatabaseOnUse()`;类型串为空时直接跳过查表(内部目录没有 `type` 属性) | +| `fe/fe-core/.../common/profile/SummaryProfile.java` | 删 `ICEBERG_SCAN_METRICS` / `PAIMON_SCAN_METRICS`(158~159 行)及其在 `EXECUTION_SUMMARY_KEYS`(218~219 行)、`EXECUTION_SUMMARY_KEYS_INDENTATION`(278~279 行)里的条目 | +| `fe/fe-connector/fe-connector-hive/.../HiveConnectorProvider.java` | `providesEventSource()` 返回 `true`(`getType()` 已核实为 `"hms"`,37~39 行) | +| `fe/fe-connector/fe-connector-hive/.../HiveScanPlanProvider.java` | `supportsFileCache()` 返回 `true` | +| `fe/fe-connector/fe-connector-iceberg/.../IcebergScanPlanProvider.java` | `supportsFileCache()` 返回 `true` | +| `fe/fe-connector/fe-connector-paimon/.../PaimonScanPlanProvider.java` | `supportsFileCache()` 返回 `true`(保持现状:白名单里有 `paimon`) | +| `fe/fe-connector/fe-connector-hudi/.../HudiScanPlanProvider.java` | `supportsFileCache()` 返回 `true`。**这一条容易漏**:hudi 表寄生在 hms 目录上,今天靠白名单里的 `"hms"` 拿到治理;改成按服务方 provider 声明之后,如果 hudi 表被转交给 hudi 兄弟连接器(`HiveConnector.getScanPlanProvider(handle)`,248~253 行按 handle 三路分派),不声明就会静默丢掉治理 | +| `fe/fe-connector/fe-connector-es/.../EsConnectorProvider.java` | `defaultDatabaseOnUse()` 返回 `Optional.of(EsConnectorMetadata.DEFAULT_DB)`(43 行已有常量,别再写一遍字面量) | +| `fe/fe-connector/fe-connector-iceberg/.../IcebergScanProfileReporter.java` | 只改 52 行注释:分组名是连接器自选的剖析子节点名,与 fe-core 常量无耦合;`GROUP_NAME` 字面量与测试断言保留(它是用户可见名,值得钉住) | +| `fe/fe-connector/fe-connector-paimon/.../PaimonScanMetrics.java` | 同上,只改 47 行注释 | +| `fe/fe-core/src/test/.../scan/PluginDrivenScanNodeScanProfileTest.java` | 删掉 `groupNameConstantsMatchConnectorLiterals`(92~99 行,断言两个即将删除的常量);其余用例(分组合并、两个扫描子节点)不动 | + +**保持 `false` 不动的连接器**(今天不在白名单里,改后必须仍然不做准入判定):`MaxComputeScanPlanProvider`、`TrinoScanPlanProvider`、`JdbcScanPlanProvider`、`EsScanPlanProvider`。不要「顺手都开上」。 + +### 5.3 明确不要顺手做的事 + +- **不要给 `Connector` 也加一份 `providesEventSource()`。** 同一能力两个高度会立刻产生「哪个是真的」的问题;`Connector.getEventSource()` 仍是唯一事实来源,provider 上那个只是「未初始化时的先行声明」,靠 javadoc 约束一致,并由 5.1 说明的原因决定它必须在 provider 上。 +- **不要顺手改事件驱动的其它行为**:`!isInitialized()` 的一次性守卫、`makeSureInitialized()` 的吞异常重试、self-heal 的游标重置(149 行)都是刻意对齐迁移前行为的,本任务只换判据。 +- **不要把 `defaultDatabaseOnUse()` 扩成「默认目录/默认会话属性」框架**,也不要顺手改 `changeDb`。只做一个可选库名。 +- **不要顺手删 `SummaryProfile` 里其它看起来源专有的常量**(`HMS_ADD_PARTITION_TIME`、`GET_PARTITIONS_TIME` 等):那些有真实赋值点(`GET_PARTITIONS_TIME` 在 635 行、`HMS_ADD_PARTITION_TIME` 在 713~714 行,等等),删了就是功能回归。本任务只删已核实无赋值点的那两个。 +- **不要把 `CACHEABLE_CATALOGS` 的语义换成「是否 JNI 格式」之类的现场推断**(例如照 `fileFormatType == FORMAT_JNI` 判断):扫描级的格式判定另有一套坑,且那仍是引擎在猜连接器的事实。就用连接器声明。 +- **不要为这三处写 shell / 正则构建门禁**(例如「grep 不允许出现 `"hms".equalsIgnoreCase`」):本仓库已有结论,这类门禁只适合存在性与前缀类不变量,判断语言语义的门禁误报比漏报更毒。用单测 + 评审。 +- **不要顺手动 `CatalogFactory.SPI_READY_TYPES`**(`fe/fe-core/.../CatalogFactory.java:56-57`)。那也是一张类型名白名单,但它是另一件事(决定一个类型能不能建目录),风险与验证面完全不同,属于另一条任务。 + +## 六、怎么验证 + +**编译门禁(最强单一信号)**:全反应堆**含测试源**的编译 + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T 1C test-compile +``` + +不要加任何跳过测试编译的参数。理由:新增的是 SPI 默认方法 + 各连接器覆写,编不过的地方(比如某个连接器模块看不到 `Optional` import、或测试仍引用被删的常量)只有把测试源一起编译才会暴露。 + +**单元测试**(跑测试时必须禁用 maven build cache,否则 surefire 会被静默跳过而 `BUILD SUCCESS` 是空的): + +1. `MetastoreEventSyncDriver` 的预热筛选:现在没有任何直测这个类行为的测试(已核实唯一提到它的是 `fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalMetaIdMgrTest.java`,那里只把它当协作者 `Mockito.mock` 掉、不验证它自己的筛选逻辑),需要新建一个。断言的是**意图**而不是形状:注册两个假 provider(一个声明有事件源、一个不声明)+ 两个未初始化的假插件目录,跑一次 `runAfterCatalogReady()`,断言只有前者的目录被 `makeSureInitialized()` 触碰过(用计数器记录),后者**一次都没有被触碰**(这条就是「空闲目录不得被强制初始化」的守卫,去掉声明位判定后这条会失败——即它能在业务逻辑变化时失败)。 +2. 文件缓存准入的适用性:给 `PluginDrivenScanNode` 加用例,`supportsFileCache()` 为 `true` / `false` / provider 为 `null` 三种情形下 `isFileCacheAdmissionApplicable()` 的返回值;再补一条断言 `FileQueryScanNode` 的默认实现为 `false`(现有 `FileQueryScanNodeTest` 里已有可复用的 `TestFileQueryScanNode`,63 行)。 +3. `changeCatalog` 的默认库:假 provider 声明 `defaultDatabaseOnUse()` 返回某个库名,断言切换后 `ctx.getDatabase()` 落到该库、且**覆盖了**先前记住的 `lastDb`(这条钉住的是现状语义,不是新语义);再断言未声明的目录不改动 `lastDb` 恢复结果。 +4. 剖析常量删除:`PluginDrivenScanNodeScanProfileTest` 剩余用例(分组 get-or-create、两个扫描子节点各自的指标)必须继续通过——它们证明扫描剖析的分组不依赖被删的常量。连接器侧 `IcebergScanProfileReporterTest`(104 行)、`PaimonScanMetricsTest`(80 行)对自身 `GROUP_NAME` 字面量的断言保留不动。 + +**变异验证**(推荐做,成本很低):把新加的三个默认方法的默认值分别从 `false`/空翻成 `true`/非空,确认至少有一条测试变红;再把某个连接器的覆写删掉,确认对应测试变红。若翻转后全绿,说明测试没有真的在验证行为。 + +**端到端回归**:本地无集群时不跑,需要集群的择机补。要点是三条: + +- 一个 hms 目录在 FE 重启后不查询、直接等事件同步(验证预热路径仍然生效,行为与改动前一致); +- hms 目录下的 hive / iceberg / hudi 表 + 独立 iceberg / paimon 目录,在打开 `enable_file_cache_admission_control` 并配了库级准入规则时,规则仍然生效(验证白名单→能力位迁移零行为差); +- `SWITCH ;` 之后 `SELECT DATABASE()` 仍是 `default_db`。 + +**人工核对**:打开一条普通内表查询的剖析,确认 `Iceberg Scan Metrics: N/A` / `Paimon Scan Metrics: N/A` 两行消失,而 iceberg / paimon 查询的扫描指标子树照旧出现。 + +## 七、风险与回退 + +| 风险 | 说明与缓解 | +|---|---| +| 文件缓存准入治理漏声明 → 静默丢治理 | 最需要小心的一处,尤其是 hudi(今天靠 hms 白名单覆盖,改后要靠 hudi 兄弟的 provider 声明)。缓解:改动清单里逐个连接器列明 true/false;单测覆盖三态;上线前用 hms 异构目录(hive + iceberg + hudi 同库)跑一次准入规则生效断言 | +| provider 声明与 `Connector.getEventSource()` 不一致 | 若 provider 声明了 `true` 但连接器实际不返回事件源,后果只是白做一次强制初始化(132 行仍会判 `null` 跳过),不影响正确性;反向(声明 false 但有事件源)等于保持今天的缺陷。靠 javadoc 明确「两者必须一致」+ 连接器侧单测断言 | +| 从 fe-core 调用插件代码的类加载器 | 三个新方法都是返回常量的纯 getter(没有远程调用、没有按名反射),与已在用的 `provider.getType()` / `supports()` 同性质,不需要固定线程上下文类加载器。评审时确认没有人在实现里做重活 | +| 删剖析常量导致外部工具解析失败 | 已核实这两行恒为 `N/A`、无赋值点,且 `regression-test/` 与 `docs/` 里零引用(grep `"Scan Metrics"` 无命中)。若某个外部脚本硬解析执行摘要行数,会受影响——这属于删掉一条恒为空的行的正常代价 | +| `changeCatalog` 每次切目录多一次 provider 遍历 | provider 列表是个位数的 `CopyOnWriteArrayList`,`supports()` 契约要求廉价且无网络调用;切目录不是热点路径 | + +**回退**:四处彼此独立,建议拆成 4 个提交(事件同步预热 / 文件缓存能力位 / 默认库 / 剖析常量),任一处出问题单独回退即可。SPI 上新增的都是**默认方法**,回退 fe-core 侧调用点后接口留着也不影响任何连接器编译。本任务不涉及 Gson 持久化格式与 thrift 有线格式,无兼容性尾巴。 + +## 八、相关背景 + +- 调研报告 `plan-doc/connector-public-interface-cleanup/audit-report.md`: + - 第 4.2 节「软阻塞:连接器能跑,但拿不到与既有数据源同等的行为」——按类型名字符串判定的四处清单(含表格),以及第四处剖析常量的提及。表格里第四行(配置项逐键手工转发)**不属于**本任务,见报告 4.5 节。 + - 附录 A 第 5 条(事件驱动里硬编码 `"hms"`)与第 30 条的复核收窄(`getEventSource` 的中立承诺在未初始化目录上是假的)——两处合起来给出事件同步预热的准确影响面:「本 FE 上从未初始化过的目录不会被强制预热」,不是「永远不会被激活」。 + - 附录 A 第 13 条——剖析分组名必须匹配 fe-core 的源专有常量表,结论是那两个常量应删。 + - 附录 A 第 15 条——文件缓存目录类型白名单当前不是 bug、只是扩展点。 + - 第 4.3 节「不阻塞但仍是按类型分支的地方」——`PluginDrivenExternalTable.getEngine()` 那两张同样按类型名的展示名表,报告结论是**不下沉**、只合并去重,不要顺手卷进本任务。 +- 已有的能力位 opt-in 先例:`ConnectorScanPlanProvider.supportsBatchScan`(250 行)、`supportsTableSample`(268 行)、`scannedPartitionCount`(291 行)——新加的 `supportsFileCache()` 照抄它们的形状与文档风格。 +- 同任务集:编号 07《把设计规则写下来》——本任务确立的「按类型查 provider 用于未初始化时刻的判定,碰连接器实例会引入强制初始化副作用」这条经验,值得写进规则文档。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/17-typed-per-table-capabilities.md b/plan-doc/connector-public-interface-cleanup/tasks/17-typed-per-table-capabilities.md new file mode 100644 index 00000000000000..cd0ef9634680c3 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/17-typed-per-table-capabilities.md @@ -0,0 +1,297 @@ +# 17. 把按表能力从字符串升级为类型化集合,并删掉写特性的镜像方法 + +> **优先级**:第四优先级(兑现承诺) | **风险**:中 | **前置依赖**:07 号(能力声明的三层规则由它写下来;本任务是按那份规则去改代码。07 号只动注释,不做也能编译,但先做能省一次返工) +> **影响模块**:`fe-connector-api`、`fe-connector-hive`、`fe-core`(含 `fe-core` 与各连接器的测试源) +> **预计改动规模**:约 20 个文件;第一、二部分合计净删约 60 行、新增约 90 行;第三部分净删约 40 行、新增约 60 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +--- + +## 一、一句话说明这个任务要解决什么 + +「一个连接器怎么告诉引擎它支持某项可选能力」这件事上,公共接口里有两处形状不对:**按表细化的能力靠一串逗号分隔的字符串塞在属性表里**(拼错不报错、而且只有一小部分能力真的生效),**写路径的 7 项特性在入口接口上又被镜像了一遍**(同一个事实有两个可覆写的入口,一旦分叉没有任何测试能发现)。本任务把前者换成类型化的能力集合、把后者换成公共模块里的静态派生函数,并把「哪些能力可以按表细化、哪些天生只能按目录声明」这件事在枚举上逐条写清楚。 + +--- + +## 二、背景:现在的代码是怎么写的 + +### 2.1 连接器级能力:类型化集合 + +`Connector.getCapabilities()`(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:211`)返回 `Set`,默认空集。`ConnectorCapability`(同目录 `ConnectorCapability.java:30`)今天有 **13 个常量**:`SUPPORTS_MVCC_SNAPSHOT`、`SUPPORTS_PASSTHROUGH_QUERY`、`SUPPORTS_PARTITION_STATS`、`SUPPORTS_COLUMN_AUTO_ANALYZE`、`SUPPORTS_TOPN_LAZY_MATERIALIZE`、`SUPPORTS_SHOW_CREATE_DDL`、`SUPPORTS_VIEW`、`SUPPORTS_NESTED_COLUMN_PRUNE`、`SUPPORTS_METADATA_PRELOAD`、`SUPPORTS_USER_SESSION`、`SUPPORTS_SAMPLE_ANALYZE`、`SUPPORTS_SORT_ORDER`、`SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE`。这一条通道本身没问题。 + +### 2.2 按表能力:一串逗号分隔的字符串 + +`ConnectorTableSchema.java:98` 定义了一个保留属性键: + +```java +public static final String PER_TABLE_CAPABILITIES_KEY = INTERNAL_KEY_PREFIX + "connector.per-table-capabilities"; +``` + +键名展开是 `__internal.connector.per-table-capabilities`,它和另外 6 个保留控制键一起登记在 `RESERVED_CONTROL_KEYS`(`ConnectorTableSchema.java:118-121`),值是**能力枚举常量名拼成的逗号分隔串**,塞进 `ConnectorTableSchema` 的 `Map properties` 里(构造器在 `:128`)。 + +**唯一的生产写入方是 hive 连接器**,两处: + +- `HiveConnectorMetadata.java:522-541`:对普通 hive 表,按文件格式逐项判断后写入 `SUPPORTS_COLUMN_AUTO_ANALYZE` / `SUPPORTS_SAMPLE_ANALYZE` / `SUPPORTS_TOPN_LAZY_MATERIALIZE`(判定谓词在 `:2209` / `:2228` / `:2239`)。 +- `HiveConnectorMetadata.java:566-588` 的 `reflectSiblingScanCapabilities`(调用点 `:487`):对 iceberg-on-HMS / hudi-on-HMS 这类由兄弟连接器代管的表,把**兄弟连接器的整个 `getCapabilities()` 集合**逐个 `name()` 拼进这个串。 + +**唯一的生产读取方是引擎的一个私有方法** `PluginDrivenExternalTable.hasScanCapability`(`fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java:302`): + +```java +if (connector.getCapabilities().contains(capability)) { + return true; +} +String csv = rawTableProperties().get(ConnectorTableSchema.PER_TABLE_CAPABILITIES_KEY); +... +for (String name : csv.split(",")) { + if (name.trim().equals(capability.name())) { return true; } +} +return false; +``` + +它的调用方只有 5 处,对应 5 个能力:`supportsColumnAutoAnalyze`(`:239`)、`supportsTopNLazyMaterialize`(`:251`)、`supportsNestedColumnPrune`(`:264`)、`supportsNestedColumnSchemaChange`(`:276`)、`supportsSampleAnalyze`(`:289`)。 + +字符串从连接器传到引擎的载体是进程内的 schema 缓存值 `PluginDrivenSchemaCacheValue.tableProperties`(`fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenSchemaCacheValue.java:57`),由 `PluginDrivenExternalTable.toSchemaCacheValue`(`:491`,末尾把整个 `tableSchema.getProperties()` 原样交给缓存值)填入,读取入口是私有的 `rawTableProperties()`(`:752`)。已核实 `SchemaCacheValue` 及其子类**没有任何 Gson 注解、不参与元数据持久化**,所以改字段没有镜像兼容负担。 + +### 2.3 其余 8 个能力:只读连接器级集合,完全绕过字符串通道 + +除 `hasScanCapability` 之外,引擎侧还有 **9 处**直接 `connector.getCapabilities().contains(...)`: + +| 位置 | 读的能力 | 作用域 | +|---|---|---| +| `PluginDrivenExternalTable.java:337` | `SUPPORTS_SHOW_CREATE_DDL` | 表 | +| `PluginDrivenExternalTable.java:353` | `SUPPORTS_VIEW` | 表 | +| `PluginDrivenExternalTable.java:438` | `SUPPORTS_METADATA_PRELOAD` | 表 | +| `ShowPartitionsCommand.java:350`(私有方法 `hasPartitionStatsCapability`,被 `:293` 与 `:390` 两处调用) | `SUPPORTS_PARTITION_STATS` | 表(但 `:390` 的 `getMetaData()` 路径上没有解析出表对象) | +| `PluginDrivenExternalCatalog.java:316` | `SUPPORTS_VIEW` | 目录(在列表名字,此时没有表) | +| `PluginDrivenExternalCatalog.java:1289` | `SUPPORTS_USER_SESSION` | 目录 | +| `PluginDrivenExternalDatabase.java:60` | `SUPPORTS_MVCC_SNAPSHOT` | 库(决定新建哪个表子类,此时表还没建出来) | +| `QueryTableValueFunction.java:78` | `SUPPORTS_PASSTHROUGH_QUERY` | 目录(表值函数,没有表) | +| `CreateTableInfo.java:794` | `SUPPORTS_SORT_ORDER` | 目录(建表语句分析期,表还不存在) | + +上表是 `fe-core` 侧的全部。连接器内部另有一处同形状的读取:`HiveConnector.java:543` 读兄弟连接器的 `SUPPORTS_USER_SESSION` 做越权守卫。它不属于引擎侧统一入口的范围,本任务不动它。 + +### 2.4 写路径的 7 项特性在两个接口上各有一份 + +真源是 `ConnectorWritePlanProvider`(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java`)上的 7 个默认方法:`supportedOperations()`(`:159`)、`supportsWriteBranch()`(`:164`)、`requiresParallelWrite()`(`:174`)、`requiresFullSchemaWriteOrder()`(`:184`)、`requiresPartitionLocalSort()`(`:194`)、`requiresPartitionHashWrite()`(`:210`)、`requiresMaterializeStaticPartitionValues()`(`:220`)。三个连接器覆写它们(`IcebergWritePlanProvider:321-345`、`HiveWritePlanProvider:124-141`、`MaxComputeWritePlanProvider:145-162`)。 + +而 `Connector` 上又镜像了 **11 个**同名方法:`supportedWriteOperations()`(`:115`)、`supportedWriteOperations(handle)`(`:126`)、`supportsWriteBranch()`(`:132`)、`supportsWriteBranch(handle)`(`:138`)、`requiresParallelWrite()`(`:144`)、`requiresFullSchemaWriteOrder()`(`:150`)、`requiresPartitionLocalSort()`(`:156`)、`requiresPartitionHashWrite()`(`:162`)、`requiresPartitionHashWrite(handle)`(`:168`)、`requiresMaterializeStaticPartitionValues()`(`:177`)、`requiresMaterializeStaticPartitionValues(handle)`(`:183`)。方法体一律是同一个形状: + +```java +default boolean requiresParallelWrite() { + ConnectorWritePlanProvider p = getWritePlanProvider(); + return p != null && p.requiresParallelWrite(); +} +``` + +已实测:**没有任何连接器覆写这 11 个方法中的任何一个**(`fe-core` 的 `ConnectorWriteDelegationTest:67` 那处覆写是覆写在 provider 上的,不是覆写在 `Connector` 上)。调用方全部在引擎侧与契约校验器里:`PluginDrivenExternalTable:178/206/223/367/388/403/423`、`PhysicalPlanTranslator:630/682`、`ConnectorContractValidator:42-70`。 + +7 项特性里只有 4 项有「按表」重载(`supportedWriteOperations` / `supportsWriteBranch` / `requiresPartitionHashWrite` / `requiresMaterializeStaticPartitionValues`),另外 3 项(`requiresParallelWrite` / `requiresFullSchemaWriteOrder` / `requiresPartitionLocalSort`)没有——这是历史上按需一个个加出来的,不是设计。 + +--- + +## 三、为什么这是个问题 + +**(1)字符串通道没有编译期约束,写错就静默失效。** 能力名写成 `"SUPPORTS_TOPN_LAZY_MATERIALIZ"`(少一个字母),编译通过、启动通过、`SHOW CREATE TABLE` 也看不出来,只是这张表永远拿不到 Top-N 延迟物化——退化成一次性能损失,没有任何报错。 + +**(2)接口文档承诺的范围和实现范围不一致。** `ConnectorTableSchema.java:82-97` 的注释写的是「本表支持的能力名」,语气上任何能力都可以按表细化;实际只有 5 个能力的读取路径会看这个串(第 2.2 节的 5 个调用方),另外 8 个只读连接器级集合。**而且哪 5 个生效这件事没有写在任何地方**,只能靠反查引擎里那个私有方法的调用方才能知道。 + +**(3)hive 网关往里塞的东西大部分被静默丢弃。** `reflectSiblingScanCapabilities` 把 iceberg 兄弟的整个能力集合都拼进串里。iceberg 的集合里含 `SUPPORTS_SHOW_CREATE_DDL` / `SUPPORTS_VIEW` / `SUPPORTS_METADATA_PRELOAD` / `SUPPORTS_SORT_ORDER` / `SUPPORTS_MVCC_SNAPSHOT`(`IcebergConnector.java:849-857`),这些在引擎侧一个都不会被读到。「多发的会被丢掉」这件事现在是**引擎读取范围窄**这个实现细节在兜着,而不是连接器明确表达了意图——它同时也是下面这个改动的陷阱:一旦引擎把读取范围扩大,这些被丢弃的能力会**突然生效**,iceberg-on-HMS 表的 `SHOW CREATE TABLE`、视图判定、元数据预载行为会在没人评审过的情况下改变。 + +**(4)名字是错的。** `hasScanCapability` 里的 "scan" 早就不成立:它服务的 5 个调用方里有 `ALTER TABLE` 嵌套列演进(`supportsNestedColumnSchemaChange`)和统计信息采集(`supportsColumnAutoAnalyze` / `supportsSampleAnalyze`),跟扫描无关。 + +**(5)写特性的镜像方法给同一个事实开了第二个可覆写入口。** 今天 0 个连接器覆写它们,所以没出事——但那是运气,不是设计保证。假设某个连接器作者在 `Connector` 上覆写 `requiresParallelWrite()` 返回 `true`,而没有同步改自己的写计划提供者:`PhysicalConnectorTableSink` 走 `Connector` 这条路会拿到 `true`,任何直接问 provider 的地方拿到 `false`,两个答案分叉。**这种分叉没有任何测试能捕获**,因为两条路各自都「符合自己的契约」。这也直接违背 07 号要写下的第二层规则:子系统内部的开关只挂在拥有它的那个 provider 上,禁止在入口接口上做镜像转发。 + +--- + +## 四、用一个最小例子说明 + +**例子一:连接器写了什么 vs 引擎实际怎么理解。** + +| 连接器写下的东西 | 今天实际发生什么 | 应该发生什么 | +|---|---|---| +| `props.put(键, "SUPPORTS_SAMPLE_ANALYZE")` | 生效 | 生效(类型化后写成 `EnumSet.of(SUPPORTS_SAMPLE_ANALYZE)`) | +| `props.put(键, "SUPPORTS_SAMPLE_ANALYZ")`(拼错) | **静默失效**:这张表永远不能 `ANALYZE ... WITH SAMPLE`,无任何日志 | **编译不过**(枚举常量不存在) | +| hive 把 iceberg 兄弟的 `SUPPORTS_SHOW_CREATE_DDL` 也拼进串里 | 引擎不读这一项,**静默丢弃**;iceberg-on-HMS 表的 `SHOW CREATE TABLE` 不走连接器渲染 | hive 只反射它确实想让表继承的那几项,不发多余的;行为不变但意图写在代码里 | + +**例子二:镜像方法怎么分叉。** 假设新连接器作者这样写(完全合法、编译通过、评审也很难看出): + +```java +class MyConnector implements Connector { + // 作者以为这是"声明我的写能力"的地方 + @Override public boolean requiresParallelWrite() { return true; } + @Override public ConnectorWritePlanProvider getWritePlanProvider() { return new MyWriteProvider(); } +} +class MyWriteProvider implements ConnectorWritePlanProvider { + // 而这里没写,默认 false +} +``` + +于是:`connector.requiresParallelWrite()` 得到 `true`,`connector.getWritePlanProvider().requiresParallelWrite()` 得到 `false`。改成静态派生函数之后,上面那个 `@Override` **根本写不出来**——`Connector` 上没有这个方法可覆写,唯一的真源由语言保证。 + +--- + +## 五、解决方案 + +### 5.1 目标状态 + +**(一)按表能力改成类型化集合。** `ConnectorTableSchema` 新增一个能力字段,字符串键删除: + +```java +// fe-connector-api:ConnectorTableSchema +private final Set tableCapabilities; + +/** 不做按表细化的连接器沿用这个构造器(能力集合为空)。 */ +public ConnectorTableSchema(String tableName, List columns, + String tableFormatType, Map properties); + +/** 需要按表细化的连接器(今天只有 hive)用这个。 */ +public ConnectorTableSchema(String tableName, List columns, + String tableFormatType, Map properties, + Set tableCapabilities); + +/** 本表在连接器级集合之外额外支持的能力;默认空集,绝不为 null。 */ +public Set getTableCapabilities(); +``` + +调研报告建议加建造器,这里**故意不加**:只多一个字段,两个构造器就够了;加建造器会与现有 23 个 `new ConnectorTableSchema(...)` 构造点长期并存,正好制造出这轮清理要消除的那种「同一件事两条通道」。 + +`PER_TABLE_CAPABILITIES_KEY` 连同它在 `RESERVED_CONTROL_KEYS` 里的登记一起删掉。 + +**(二)能力集合沿 schema 缓存往下走。** `PluginDrivenSchemaCacheValue` 增一个 `Set tableCapabilities` 字段(与已有的 `tableProperties` 并列,同样只活在进程内缓存里),`toSchemaCacheValue` 从 `tableSchema.getTableCapabilities()` 取。 + +**(三)引擎侧表作用域能力统一走一个入口。** `hasScanCapability` 改名 `hasCapability`(仍是私有),语义仍是「连接器级 ∪ 本表级」,只是本表级从解析字符串变成读集合;`PluginDrivenExternalTable` 里 3 处直读(`:337` / `:353` / `:438`)改走它。 + +**(四)目录作用域能力也统一走一个入口。** 在 `PluginDrivenExternalCatalog` 上加一个通用访问器,替掉 4 个类里重复的 `catalog instanceof ... && getConnector() != null && getConnector().getCapabilities().contains(...)` 样板: + +```java +// PluginDrivenExternalCatalog +public boolean hasConnectorCapability(ConnectorCapability capability); +``` + +这条不违反「fe-core 只出不进」:它不是数据源相关代码,是把已存在的重复判断收成一个通用访问器,整体是净删行。 + +**(五)在枚举上逐条写清作用域。** `ConnectorCapability` 每个常量的注释里补一句它是**按目录解析**还是**按目录 ∪ 按表解析**。核实后的分布是: + +- 按目录 ∪ 按表(8 个):`SUPPORTS_COLUMN_AUTO_ANALYZE`、`SUPPORTS_SAMPLE_ANALYZE`、`SUPPORTS_TOPN_LAZY_MATERIALIZE`、`SUPPORTS_NESTED_COLUMN_PRUNE`、`SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE`、`SUPPORTS_SHOW_CREATE_DDL`、`SUPPORTS_VIEW`、`SUPPORTS_METADATA_PRELOAD`。 +- 只能按目录(5 个):`SUPPORTS_MVCC_SNAPSHOT`(在表对象**建出来之前**就要用它选表子类,读表级集合会形成循环依赖)、`SUPPORTS_USER_SESSION`(目录级凭证投射)、`SUPPORTS_PASSTHROUGH_QUERY`(表值函数,没有表)、`SUPPORTS_SORT_ORDER`(建表语句分析期,表还不存在)、`SUPPORTS_PARTITION_STATS`(两个调用点必须给出一致答案,其中 `getMetaData()` 那条路径上没有表对象,见 5.3)。 + +也就是说,**「5/13」会变成「8/13,剩下 5 个天生只能按目录,并逐条写明原因」**,而不是调研报告里那句 13/13——13/13 在结构上做不到。 + +**(六)hive 收窄反射范围。** `reflectSiblingScanCapabilities` 不再反射兄弟的整个集合,改为反射一个显式列出的「打算让表继承的能力」子集:`SUPPORTS_COLUMN_AUTO_ANALYZE`、`SUPPORTS_SAMPLE_ANALYZE`、`SUPPORTS_TOPN_LAZY_MATERIALIZE`、`SUPPORTS_NESTED_COLUMN_PRUNE`、`SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE` —— 也就是今天引擎实际会读的那 5 项。取这 5 项而不是「iceberg 兄弟现在恰好声明的那 4 项」,是为了让行为不变这件事**与兄弟连接器声明了什么无关**(iceberg 今天不声明 `SUPPORTS_SAMPLE_ANALYZE`,反射它是空操作;将来若声明,行为与今天一致)。这一步**行为完全不变**(今天多发的部分本来就被丢弃),但它是第(三)步安全的前提:只有先收窄,扩大引擎读取范围才不会顺带改变 iceberg-on-HMS 表的行为。方法名里的 "Scan" 一并去掉。 + +**(七)写特性改成静态派生函数。** `fe-connector-api` 的 write 包下新增: + +```java +public final class ConnectorWriteTraits { + private ConnectorWriteTraits() {} + + /** 连接器级写计划提供者,连接器为 null 时返回 null。 */ + public static ConnectorWritePlanProvider providerOf(Connector connector); + /** 按表的写计划提供者(异构网关据 handle 选子提供者),连接器为 null 时返回 null。 */ + public static ConnectorWritePlanProvider providerOf(Connector connector, ConnectorTableHandle handle); + + // 以下 7 个是空安全解包:provider 为 null 时布尔返 false、集合返空集 + public static Set supportedOperations(ConnectorWritePlanProvider provider); + public static boolean supportsWriteBranch(ConnectorWritePlanProvider provider); + public static boolean requiresParallelWrite(ConnectorWritePlanProvider provider); + public static boolean requiresFullSchemaWriteOrder(ConnectorWritePlanProvider provider); + public static boolean requiresPartitionLocalSort(ConnectorWritePlanProvider provider); + public static boolean requiresPartitionHashWrite(ConnectorWritePlanProvider provider); + public static boolean requiresMaterializeStaticPartitionValues(ConnectorWritePlanProvider provider); +} +``` + +`Connector` 上那 11 个默认方法全部删除。调用方改成两段式,比如 `PluginDrivenExternalTable:388`: + +```java +return resolveWriteCapabilityHandle(connector) + .map(handle -> ConnectorWriteTraits.requiresPartitionHashWrite( + ConnectorWriteTraits.providerOf(connector, handle))) + .orElse(false); +``` + +**这个形状顺手把「7 项特性只有 4 项有按表形态」的缺口取消掉了**,而不是去补那 3 个缺失的重载:作用域由调用方选哪个 `providerOf` 决定,7 项特性各只有一个解包函数,任何一项都能按连接器级或按表级取。这比补 3 个重载更好——那 3 个重载**今天没有任何调用方**,而本轮清理的其它任务正在删的就是这种零使用接口面,一边删一边加新的零使用方法说不通。 + +### 5.2 改动清单 + +| 文件 | 要做什么 | +|---|---| +| `fe-connector-api/.../ConnectorTableSchema.java` | 加 `tableCapabilities` 字段、5 参构造器、`getTableCapabilities()`;第二批删 `PER_TABLE_CAPABILITIES_KEY`(`:98`)及其在 `RESERVED_CONTROL_KEYS`(`:121`)中的登记与相关注释 | +| `fe-connector-api/.../ConnectorCapability.java` | 每个常量的注释补一句作用域(按目录 / 按目录 ∪ 按表),并说明只能按目录的原因 | +| `fe-connector-api/.../Connector.java` | 删 `:115`–`:186` 的 11 个写特性镜像方法;清理随之无用的 `EnumSet` 导入 | +| `fe-connector-api/.../write/ConnectorWriteTraits.java` | **新增**:9 个静态方法(见 5.1 第七条) | +| `fe-connector-api/.../ConnectorContractValidator.java` | `:42`–`:70` 五处校验改用静态派生(先取一次 provider,再逐项解包) | +| `fe-connector-hive/.../HiveConnectorMetadata.java` | `:522-541` 改为收集 `EnumSet` 并走 5 参构造器;`:566-588` 的反射改名并收窄到显式子集;第二批删掉 CSV 写入 | +| `fe-core/.../plugin/PluginDrivenSchemaCacheValue.java` | 加 `tableCapabilities` 字段 + 取值方法(与 `tableProperties` 同款,非持久化) | +| `fe-core/.../plugin/PluginDrivenExternalTable.java` | `hasScanCapability`(`:302`)改名 `hasCapability` 并改读集合;`:337`/`:353`/`:438` 三处直读改走它;`toSchemaCacheValue`(`:491`)透传能力集合;写特性 7 处调用(`:178`/`:206`/`:223`/`:367`/`:388`/`:403`/`:423`)改用静态派生 | +| `fe-core/.../plugin/PluginDrivenExternalCatalog.java` | 加 `hasConnectorCapability`;`:316` 与 `:1289` 改用它 | +| `fe-core/.../plugin/PluginDrivenExternalDatabase.java` | `:60` 改用 `hasConnectorCapability` | +| `fe-core/.../commands/info/CreateTableInfo.java` | `:794` 改用 `hasConnectorCapability` | +| `fe-core/.../tablefunction/QueryTableValueFunction.java` | `:78` 改用 `hasConnectorCapability` | +| `fe-core/.../commands/ShowPartitionsCommand.java` | `:344-351` 改用 `hasConnectorCapability`,并把「为什么这一项保持目录级」写进注释 | +| `fe-core/.../translator/PhysicalPlanTranslator.java` | `:630`/`:682` 改用静态派生 | +| 测试源:`PluginDrivenExternalTableTest`(`:475`/`:494`/`:553`/`:562`)、`HiveConnectorMetadataSchemaTest:117`、`HiveConnectorMetadataSiblingDelegationTest:373/393`、`ConnectorWriteDelegationTest`、`ConnectorContractValidatorTest`、`PhysicalConnectorTableSinkTest`、`InsertIntoTableCommandTest`、`InsertOverwriteTableCommandTest` | 断言从「查属性表里的字符串」改成「查类型化集合」;写特性断言改走静态派生 | + +**建议分三批合入**(每批单独能编译、单独能跑测试): + +1. **第一批(双写,行为不变)**:加类型化字段与缓存字段;hive 同时发类型化集合与旧字符串;引擎按「类型化集合 ∪ 旧字符串 ∪ 连接器级」解析;hive 反射收窄到显式子集。 +2. **第二批(拆旧)**:删 `PER_TABLE_CAPABILITIES_KEY`、hive 停发字符串、引擎删字符串分支;`hasScanCapability` 改名 `hasCapability`;3 处表作用域直读改走它;`hasConnectorCapability` 落地;枚举注释补作用域。 +3. **第三批(写特性,与前两批无耦合,可并行)**:新增 `ConnectorWriteTraits`,删 `Connector` 上 11 个镜像方法,改所有调用方与测试。 + +### 5.3 明确不要顺手做的事 + +- **不要把 `SUPPORTS_PARTITION_STATS` 改成按表解析。** 它的两个调用点必须给出一致答案(`ShowPartitionsCommand:293` 决定每行有几列,`:390` 决定表头有几列),而 `getMetaData()` 那条路径上没有解析出表对象、也不适合在那里抛表不存在的异常。表头和行宽一旦不一致是可见的错误结果。保持目录级并把原因写进注释。 +- **不要顺手让 iceberg-on-HMS 表继承 `SUPPORTS_SHOW_CREATE_DDL` / `SUPPORTS_VIEW` / `SUPPORTS_METADATA_PRELOAD`。** 机制上第二批之后它就是一行改动的事(把这几项加进 hive 的反射子集),但那是真实的行为变化(`SHOW CREATE TABLE` 输出、视图判定、元数据预载),需要独立评审加异构目录端到端回归。本任务只把机制做齐,行为保持不变。 +- **不要把 `requiresParallelWrite` / `requiresFullSchemaWriteOrder` / `requiresPartitionLocalSort` 这三处引擎调用改成按表解析。** 按表解析要多做一次表句柄解析(`resolveWriteCapabilityHandle`),而对今天唯一的异构网关来说这三项在 hive 与 iceberg 上取值相同,结果必然一致——纯粹多花 CPU 换同一个答案。API 上具备按表能力,引擎按需使用。 +- **不要顺手改 `ConnectorTableSchema` 的其它 6 个保留控制键。** 分区列、主键、分布列仍是列名字符串 CSV,它们是名字而不是枚举,不在本任务范围。 +- **不要为「不许在 `Connector` 上镜像 provider 方法」写 shell 或正则门禁。** 删掉方法之后这条约束由语言保证(没有方法可覆写),门禁是多余的;本仓库已有结论:这类门禁只适合存在性与前缀类不变量。 +- **不要引入建造器。** 理由见 5.1 第一条。 + +--- + +## 六、怎么验证 + +**编译门禁(最强单一信号)**:全反应堆**含测试源**编译通过。 + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T1C test-compile +``` + +不得使用任何跳过测试源编译的参数。删掉 `Connector` 上 11 个方法之后,任何漏改的调用方都是编译错误——这是本任务第三部分的主要保障。删掉 `PER_TABLE_CAPABILITIES_KEY` 之后,任何还在引用它的生产代码或测试同样编译不过。 + +**单元测试要断言的东西**(跑测试必须禁用 maven build cache,否则 surefire 会被静默跳过而构建仍报成功): + +1. `PluginDrivenExternalTableTest`:改造已有 4 处断言(`:475`/`:494`/`:553`/`:562`),把「属性表里放字符串」换成「schema 里放类型化集合」,保留它们原有的变异说明。新增两条: + - **加法语义**:连接器级集合为空 + 本表集合含某能力 ⇒ 该能力为真;反之亦然;本表集合含 A 不得让 B 为真。 + - **新纳入的三项**:`supportsShowCreateDdl` / `supportsView` / `supportsExternalMetadataPreload` 在「连接器级没有、本表集合有」时为真(这三条是第二批新增能力的行为证据)。 +2. `HiveConnectorMetadataSchemaTest` / `HiveConnectorMetadataSiblingDelegationTest`:断言点从读属性串改为读 `getTableCapabilities()`;**新增一条断言反射子集**:给一个声明了 `SUPPORTS_SHOW_CREATE_DDL` + `SUPPORTS_SORT_ORDER` + `SUPPORTS_COLUMN_AUTO_ANALYZE` 的假兄弟连接器,断言反射结果**只含** `SUPPORTS_COLUMN_AUTO_ANALYZE`。变异说明:如果有人把反射改回「整个集合」,这条断言变红——它正是防止 iceberg-on-HMS 行为被意外改变的那道闸。 +3. `ConnectorWriteDelegationTest` / `ConnectorContractValidatorTest`:改走静态派生后,断言「provider 为 null 时 7 项解包全部退化为 false / 空集」和「provider 覆写为 true 时解包为 true」。这两条编码的意图是:真源只有 provider 一处。 +4. **不需要写「没有连接器覆写镜像方法」这类守卫测试**:方法删掉之后这件事由语言保证。 + +**是否需要端到端回归**:本任务的三批改动都以行为不变为目标,不新增用户可见能力,因此不需要新增 e2e 用例。但第二批合入后建议在异构 HMS 目录上跑一遍现有的 hive/iceberg-on-HMS 统计与查询回归(`ANALYZE`、带 `LIMIT ... ORDER BY` 的 Top-N、嵌套列裁剪、`SHOW CREATE TABLE`),确认按表能力的三项仍生效、`SHOW CREATE TABLE` 输出**没有**变化。 + +--- + +## 七、风险与回退 + +| 风险 | 说明与对策 | +|---|---| +| 扩大引擎读取范围时顺带改变 iceberg-on-HMS 行为 | 这是本任务最大的风险点。对策是顺序:先收窄 hive 反射(行为不变),再扩大引擎读取范围。收窄那一步配一条断言子集内容的单测(见六.2) | +| 按表能力在某条路径上丢失 | 载体从属性 `Map` 换成独立字段,任何忘记透传的地方都会让对应表退化成「只有连接器级能力」——不报错、只是性能或功能静默退化。对策是六.1 的加法语义断言 + 逐项断言三个新纳入能力 | +| 删镜像方法漏改调用方 | 编译期即失败,无运行时风险 | +| 静态派生函数写反极性(该 `false` 写成 `true`) | 由六.3 的空 provider 退化断言覆盖 | +| 缓存值改字段影响元数据兼容 | 已核实 `SchemaCacheValue` 及 `PluginDrivenSchemaCacheValue` 无任何 Gson 注解、不参与持久化,只是进程内 schema 缓存;无兼容负担 | + +**回退**:三批各自是独立提交,任一批可单独 `revert`。第三批(写特性)与前两批无代码耦合,回退互不影响。第二批回退后第一批的双写状态仍是自洽的可运行状态。 + +--- + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` + - 第五章「主题二:『声明一项能力』有多条并行通道」(5.1 现象 / 5.2 为什么是问题 / 5.3 建议三步)——本任务对应其中的第二步与第三步,第一步是 07 号。 + - 第 6.1 节的接口规模表中 `Connector` 那一行——把混在一起的职责逐项列出,其中写着「9 个写特性镜像」;报告当时只数了 9 个返回布尔的,实测是 11 个(另有 2 个返回写操作集合),本文正文的口径更准。 + - 附录 A 第 76 / 77 / 87 / 108 / 133 条——能力声明多通道、按表能力 CSV、写侧三层并存表达、入口接口职责过载的原始记录。 + - 第 17.2 节「直译后没接线的真缺陷」第 9 条——「在入口接口上做 provider 方法的镜像转发」,Trino 里不存在这种东西,能力永远只挂在归属它的那个 provider 上。 +- `tasks/07-write-down-the-design-rules.md`:能力声明的三层规则由它写下来,本任务是按规则改代码。其中第二层「禁止在 `Connector` 上做镜像转发」正是本任务第三部分要落实的内容。 +- `tasks/11-delete-dead-surface-batch-one.md` / `tasks/12-delete-dead-surface-batch-two.md` / `tasks/13-delete-scan-range-type-enum.md`:同一轮里删零使用接口面的任务。本任务 5.1 第七条决定「不补 3 个零调用方重载」正是为了不和它们互相打架。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/18-push-down-create-table-capability.md b/plan-doc/connector-public-interface-cleanup/tasks/18-push-down-create-table-capability.md new file mode 100644 index 00000000000000..3698ae513955b1 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/18-push-down-create-table-capability.md @@ -0,0 +1,251 @@ +# 18. 把建表能力从引擎白名单下沉为连接器声明(高风险,需端到端兜底) + +> **优先级**:第四优先级(高风险) | **风险**:高 | **前置依赖**:无(与「删除目录类型白名单」那个任务改的是同一批引擎侧硬编码但不同文件,先后顺序不影响编译) +> **影响模块**:`fe-connector-api`(新增一个连接器声明方法 + 两个能力位)、`fe-connector-hive`、`fe-connector-iceberg`、`fe-connector-paimon`、`fe-connector-maxcompute`(各声明一次)、`fe-core`(`CreateTableInfo` 三处硬编码退化为读声明,只删不加数据源相关代码) +> **预计改动规模**:约 9~11 个文件;`fe-core` 净删约 25 行、新增约 35 行(含注释),连接器侧每个 5~15 行,测试与端到端用例新增约 150 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +今天一个新连接器想支持 `CREATE TABLE`,必须去 `fe-core` 的 `CreateTableInfo` 里改三处按引擎名写死的清单(目录类型到引擎名的映射、可接受的引擎名、可接受 `PARTITION BY` / `DISTRIBUTED BY` 的引擎名),本任务把这三处的判据换成「问连接器自己」:连接器声明它建表时用哪个引擎名(不声明就等于不支持建表),再用两个能力位声明它接不接分区子句和分桶子句。 + +## 二、背景:现在的代码是怎么写的 + +所有外部目录现在都是同一个类:`CatalogFactory.java:56-57` 的 `SPI_READY_TYPES` 已经包含 `jdbc`、`es`、`trino-connector`、`max_compute`、`paimon`、`iceberg`、`hms` 七种类型,它们全部被造成 `PluginDrivenExternalCatalog`(`CatalogFactory.java:110-117`)。也就是说建表分析期拿到的目录对象上,随时可以问到背后的连接器。但 `CreateTableInfo` 至今还是按字符串判断的。 + +相关代码集中在一个文件:`fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java`。 + +**(1)引擎名常量表**,`:115-124`,十个常量:`olap` / `jdbc` / `elasticsearch` / `odbc` / `mysql` / `broker` / `hive` / `iceberg` / `paimon` / `maxcompute`。后四个是外部目录建表用的,前六个是内部表与已下线的老外部表语法。 + +**(2)目录类型到引擎名的映射**,`:916-932`: + +```java +private static String pluginCatalogTypeToEngine(PluginDrivenExternalCatalog catalog) { + switch (catalog.getType()) { + case "max_compute": return ENGINE_MAXCOMPUTE; + case "paimon": return ENGINE_PAIMON; + case "iceberg": return ENGINE_ICEBERG; + case "hms": return ENGINE_HIVE; + default: return null; + } +} +``` + +它的注释 `:907-915` 自己写着这段与 `PluginDrivenExternalTable.getEngine()/getEngineTableTypeName()` 是一对镜像,「**两个 switch 必须保持同步**」(原文 `the two switches must stay in sync`),并说明返回 `null` 表示这个类型不支持建表。 + +它有两个读者: + +- `paddingEngineName`(`:886-905`):用户没写 `ENGINE=` 时补一个。内部目录补 `olap`;插件目录若映射非空就补映射值;否则抛 `Current catalog does not support create table: <目录名>`(`:902`)。 +- `checkEngineWithCatalog`(`:375-394`,在 `validate` 的 `:441` 被调用):用户显式写了 `ENGINE=` 时校验一致性,不一致抛 `This catalog can only use \`<映射值>\` engine.`(`:391`);`ENGINE=olap` 写在外部目录里另有一条专门文案(`:378-379`)。映射返回 `null` 的类型(`jdbc` / `es` / `trino-connector`)在这里**一律放过**,不抛。 + +**(3)可接受的引擎名清单**,`checkEngineName`(`:954-986`),核心是 `:955-958` 一串 `equals` 的或: + +```java +if (engineName.equals(ENGINE_MYSQL) || engineName.equals(ENGINE_ODBC) || engineName.equals(ENGINE_BROKER) + || engineName.equals(ENGINE_ELASTICSEARCH) || engineName.equals(ENGINE_HIVE) + || engineName.equals(ENGINE_ICEBERG) || engineName.equals(ENGINE_JDBC) + || engineName.equals(ENGINE_PAIMON) || engineName.equals(ENGINE_MAXCOMPUTE)) { + if (!isExternal) { isExternal = true; } // 兼容:外部引擎名隐含 external +} else { + if (isExternal) { throw ... "Do not support external table with engine name = olap"; } + else if (!engineName.equals(ENGINE_OLAP)) { throw ... "Do not support table with engine name = " + engineName; } +} +``` + +不在这张清单里的名字,在 `:968-969` 被拒。同方法后半段还有临时表拒绝(`:973-975`)和 `odbc` / `mysql` / `broker` 的硬下线文案(`:980-985`)。 + +**(4)分区与分桶子句的允许列表**,`analyzeEngine`(`:1125-1147`): + +```java +if (engineName.equals(ENGINE_ELASTICSEARCH)) { + if (distributionDesc != null) { throw ... "could not support distribution clause"; } +} else if (!engineName.equals(ENGINE_OLAP)) { + if (!engineName.equals(ENGINE_HIVE) && !engineName.equals(ENGINE_MAXCOMPUTE) && distributionDesc != null) { + throw ... "Create " + engineName + " table should not contain distribution desc"; + } + if (!engineName.equals(ENGINE_HIVE) && !engineName.equals(ENGINE_ICEBERG) + && !engineName.equals(ENGINE_PAIMON) && !engineName.equals(ENGINE_MAXCOMPUTE) && partitionDesc != null) { + throw ... "Create " + engineName + " table should not contain partition desc"; + } +} +``` + +即:分桶子句只有 `hive` 与 `maxcompute` 接受;分区子句 `hive` / `iceberg` / `paimon` / `maxcompute` 接受。注意 `elasticsearch` 走的是**独立的前置分支并且直接结束**,它既有自己的分桶文案,也从来不检查分区子句——这是既有怪癖,本任务不碰。 + +**(5)同一个文件里已经有一个正确范式**:写入排序子句 `ORDER BY` 的门(`:791-800`)已经从「引擎名等于 iceberg」改成了读能力位: + +```java +boolean supportsSortOrder = catalog instanceof PluginDrivenExternalCatalog + && ((PluginDrivenExternalCatalog) catalog).getConnector().getCapabilities() + .contains(ConnectorCapability.SUPPORTS_SORT_ORDER); +``` + +本任务要做的就是把上面(2)(3)(4)改成同一形状。能力枚举在 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorCapability.java`,`SUPPORTS_SORT_ORDER` 在 `:165`(文档 `:153-164`),枚举末项是 `:182` 的 `SUPPORTS_NESTED_COLUMN_SCHEMA_CHANGE`。连接器侧的取得器是 `Connector.getCapabilities()`(`Connector.java:210-213`,默认返回空集)。 + +**校验发生的先后顺序**(这是本任务最重要的事实,`validate(ConnectContext)` 内): + +| 顺序 | 位置 | 做什么 | +|---|---|---| +| 1 | `:413` | `paddingEngineName` —— 补引擎名,或抛「本目录不支持建表」 | +| 2 | `:414` | `checkEngineName` —— 引擎名是否在可接受清单里 | +| 3 | `:441` | `checkEngineWithCatalog` —— 引擎名与目录是否匹配 | +| 4 | `:871` | `analyzeEngine` —— 分区 / 分桶子句是否允许 | + +## 三、为什么这是个问题 + +**违反的原则**:连接器是独立打包的插件,应该「装上就能用」;但建表这一项能力的开关握在 `fe-core` 手里。这正是本轮整治要收掉的那类卡点——树外连接器根本没法通过改 `fe-core` 来放行自己。 + +**真实后果有三层**: + +1. **新连接器必须改公共模块**。哪怕连接器已经把 `createTable` 实现得完整无缺,只要没在 `CreateTableInfo` 里登记,`CREATE TABLE` 在分析期就被拒,永远到不了连接器。 +2. **一处已被写进注释的重复**。`:911-912` 的「两个 switch 必须保持同步」是开发者自己留下的告警:建表引擎名和展示引擎名是两张各自维护的表,`hms` 目录在建表侧要映射成 `hive`、在展示侧却必须显示 `hms`(`PluginDrivenExternalTable.java:1267-1298`)。这种「必须靠人记住同步」的结构迟早漏改。 +3. **能力与实现分处两地,容易出现半开状态**。分区/分桶允许列表在 `fe-core`,真正的分区语义校验在连接器的 `createTable` 里。今天四个连接器恰好对齐,但对齐关系只存在于口头。 + +这不是正确性缺陷:用户今天观察不到错误结果,只会观察到「这个连接器不支持建表」。所以本任务的验收标准是**行为逐字不变**,而不是修出什么新行为。 + +## 四、用一个最小例子说明 + +假设我要新写一个连接器 `mytable`,它的 `createTable` 已经实现好了,也支持分区。今天我必须动 `fe-core`: + +| 我想做的事 | 今天必须在 `CreateTableInfo` 里加什么 | 不加会怎样 | +|---|---|---| +| `CREATE TABLE t (id int)`(省略 ENGINE) | `pluginCatalogTypeToEngine` 加一个 `case "mytable": return ENGINE_MYTABLE;` | 抛 `Current catalog does not support create table: mycat` | +| `CREATE TABLE t (id int) ENGINE=mytable` | 引擎名常量 `ENGINE_MYTABLE` + `checkEngineName` 的或链加一项 | 抛 `Do not support table with engine name = mytable` | +| `CREATE TABLE t (id int) PARTITION BY LIST (id) ()` | `analyzeEngine` 分区允许列表加一项 | 抛 `Create mytable table should not contain partition desc` | + +改完之后,我在自己的连接器里写: + +```java +@Override +public Optional getCreateTableEngineName() { + return Optional.of("mytable"); // 不覆写 = 不支持建表,行为与今天的 jdbc/es 一致 +} + +@Override +public Set getCapabilities() { + return ImmutableSet.of(ConnectorCapability.SUPPORTS_CREATE_TABLE_PARTITION_BY); +} +``` + +`fe-core` 一行不改,上面三条 SQL 全部按预期工作。 + +## 五、解决方案 + +### 5.1 目标状态 + +**新增一个连接器声明**,放在 `fe-connector-api` 的 `Connector` 接口上(紧邻 `getCapabilities()`): + +```java +/** + * 本目录建表时使用的引擎名(CREATE TABLE ... ENGINE=<名字>)。返回空表示本连接器不支持建表: + * 省略 ENGINE 的建表被拒("Current catalog does not support create table"),显式 ENGINE 不做 + * 目录一致性校验(与今天的 jdbc / es / trino-connector 完全一致)。 + * 声明的名字同时成为可接受的引擎名,无需在引擎侧登记。 + * 注意:这与 SHOW TABLE STATUS 等处的「展示引擎名」是两件事(hms 目录建表用 hive、展示用 hms)。 + */ +default Optional getCreateTableEngineName() { + return Optional.empty(); +} +``` + +**新增两个能力位**,加在 `ConnectorCapability` 末尾(命名与既有的 `SUPPORTS_SORT_ORDER` 同族,都是建表子句门): + +- `SUPPORTS_CREATE_TABLE_PARTITION_BY` —— 接受 `PARTITION BY` 子句;声明者:hive、iceberg、paimon、maxcompute。 +- `SUPPORTS_CREATE_TABLE_DISTRIBUTED_BY` —— 接受 `DISTRIBUTED BY` 子句;声明者:hive、maxcompute。 + +**引擎侧读法**:`CreateTableInfo` 加一个私有辅助方法,按目录名取声明。这里有一个必须显式处理的点:`PluginDrivenExternalCatalog.getConnector()`(`:347-350`)内部会调 `makeSureInitialized()`,即**强制初始化目录**。同类的能力读取在同一个类里已有两个不强制初始化的先例(`:377-389` 的 `overlayMetaCacheConfig`、`:1287-1290` 的 `supportsUserSession`,都直接读字段并对 `null` 早退)。建议在 `PluginDrivenExternalCatalog` 上加一个不强制初始化的读法:连接器字段非空时直接读声明;字段为空(元数据重放时插件缺失的降级目录)才调 `makeSureInitialized()`,让「插件没装」这条清晰错误如常抛出——这与今天的最终结果一致(今天是靠 `getType()` 先补上引擎名、随后在解析库时才抛插件缺失),只是提前了。 + +**三处硬编码的目标形状**: + +1. `pluginCatalogTypeToEngine` 整个删除,两个调用点改成读声明。文案与抛出条件一字不动。 +2. `checkEngineName` 的或链**保留原样**,末尾追加一个析取项:或者引擎名等于目标目录声明的建表引擎名。今天四个声明值本来就在或链里,所以行为零变化;新连接器则无需登记。 +3. `analyzeEngine` 的两条允许列表改成读能力位,两条 `throw` 的文案与拼接方式(`"Create " + engineName + " table should not contain ..."`)一字不动,且**两条检查的先后顺序不变**(先分桶后分区)。`elasticsearch` 前置分支整块不动。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe-connector-api/.../api/Connector.java`(`:210` 附近) | 新增 `getCreateTableEngineName()` 默认方法,返回 `Optional.empty()`;文档写清「返回空=不支持建表」与「区别于展示引擎名」 | +| `fe-connector-api/.../api/ConnectorCapability.java`(`:182` 之后) | 新增两个枚举值,各带完整文档:谁声明、谁不声明、对应哪条 SQL 子句、与写路径的分区/排序特性有何区别(照 `:153-164` 的写法) | +| `fe-connector-hive/.../HiveConnector.java`(`getCapabilities` 在 `:297`) | 声明建表引擎名 `hive`(目录类型是 `hms`,这里名字不同是**刻意的**,要写注释);能力集加分区位与分桶位 | +| `fe-connector-iceberg/.../IcebergConnector.java`(`getCapabilities` 在 `:815`) | 声明 `iceberg`;能力集加分区位(**不加**分桶位) | +| `fe-connector-paimon/.../PaimonConnector.java`(`getCapabilities` 在 `:322`) | 声明 `paimon`;能力集加分区位 | +| `fe-connector-maxcompute/.../MaxComputeDorisConnector.java`(`:50`,目前没有 `getCapabilities` 覆写) | 声明 `maxcompute`;新增 `getCapabilities` 覆写,加分区位与分桶位 | +| `fe-core/.../plugin/PluginDrivenExternalCatalog.java` | 新增一个不强制初始化的建表声明读法(连接器字段为空时才 `makeSureInitialized()`),并补一个读能力位的同款方法供 `analyzeEngine` 用 | +| `fe-core/.../info/CreateTableInfo.java` | 删 `pluginCatalogTypeToEngine`(`:907-932`,含那段「两个 switch 必须同步」的注释);`paddingEngineName`(`:896-900`)与 `checkEngineWithCatalog`(`:389-392`)改读声明;`checkEngineName`(`:955-958`)追加一个析取项;`analyzeEngine`(`:1134-1145`)两条允许列表改读能力位。`ENGINE_*` 常量与或链里的既有名字**全部保留** | +| `fe-core` 单元测试 `CreateTableInfoEngineCatalogTest.java` | 现有 9 个用例的桩从「mock `getType()` 返回类型串」改成「mock 建表声明」,并**新增**分区/分桶两条子句门的用例(现在完全没有覆盖) | +| 端到端用例(见第六节) | 先固化文案断言,再改代码 | + +`analyzeEngine` 有第二个调用者 `CreateMTMVInfo.java:291`,但它在 `:283` 把引擎名固定设为 `ENGINE_OLAP`,会走 `!olap` 判断而跳过整段,因此不受影响——改完要复核这一点仍然成立。 + +### 5.3 明确不要顺手做的事 + +- **不要删或收窄 `checkEngineName` 的或链**。`mysql` / `odbc` / `broker` / `elasticsearch` / `jdbc` 是用户可见的历史 SQL 语法,`InternalCatalog`(`fe/fe-core/.../datasource/InternalCatalog.java:1268-1295`)为每一个都准备了专门的下线文案。更要紧的是:`iceberg` / `paimon` / `maxcompute` 也**必须留在或链里**——`CREATE TABLE ... ENGINE=jdbc` 写在一个 iceberg 目录里,今天先过或链、再被目录一致性检查拒掉,文案是 `This catalog can only use \`iceberg\` engine`,而这条断言在 `regression-test/suites/external_table_p0/iceberg/write/test_iceberg_create_table.groovy:70-72` 与 `.../hive/ddl/test_hive_ddl.groovy:730-733` 都是活的。把或链改成「只接受已声明的名字」会让文案变成 `Do not support table with engine name = jdbc`,直接挂两个端到端用例。 +- **不要合并「建表引擎名」与「展示引擎名」**。`PluginDrivenExternalTable.getEngine()`(`:1267-1298`)是另一件事,`hms` 目录建表用 `hive`、展示必须是 `hms`,`max_compute` / `trino-connector` 的展示名甚至是 `null`。那段 switch 属于另一个任务,本任务只删建表侧这一张表,并把注释里那句「两个 switch 必须同步」的约束**如实改写**成「展示侧仍是独立的一张表」。 +- **不要动 `elasticsearch` 的前置分支**。它的分桶文案(`could not support distribution clause`)与「不检查分区」的怪癖都要原样保留。把它并进通用分支会同时改掉一条文案、新增一条拒绝。 +- **不要让 hive 连接器声明它的 iceberg 兄弟的引擎名**。hms 目录上 `ENGINE=iceberg` 今天是被拒的(`test_hive_ddl.groovy:725-728` 有断言)。建表引擎名保持单值。 +- **不要顺手把建表时的分区列校验从连接器搬回引擎**,也不要在 `fe-core` 里新增任何属性解析或分区推导 helper。本阶段 `fe-core` 只出不进。 +- **不要为这两个能力位写 shell 或正则的构建门禁**。这类不变量改完之后由类型系统保证,语义级校验交给单测与评审。 + +## 六、怎么验证 + +**必须先固化、再改代码。** 第一步只写测试、不动一行生产代码,跑通并确认全部通过(说明它们抓的是当前行为);第二步再改代码,要求同一套测试仍然全绿。 + +**第一步:把五种形态的错误文案固化成断言。** + +单元测试放在既有的 `fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfoEngineCatalogTest.java`(已有 264 行,覆盖了前四种形态中的三种),补齐到六条: + +| 形态 | 断言 | 现状 | +|---|---|---| +| 省略 `ENGINE` | maxcompute 目录补出 `maxcompute`、hms 目录补出 `hive`(含 CTAS 入口) | 已有(`:119-131`、`:203-235`) | +| 显式正确 `ENGINE` | `checkEngineWithCatalog` 不抛 | 已有(`:166-173`、`:249-256`) | +| 显式错误 `ENGINE` | 抛 `AnalysisException`,消息**逐字**为 `This catalog can only use \`<名字>\` engine.` | 已有但只断言了抛异常,**要补上文案逐字断言** | +| jdbc / es 目录建表 | 省略 `ENGINE` 抛出且消息含 `does not support create table`;显式 `ENGINE=jdbc` 不在一致性检查处抛 | 已有(`:175-193`) | +| 带 `PARTITION BY` | 声明分区位的目标放过;未声明的抛 `Create table should not contain partition desc` | **完全没有覆盖,必须新增** | +| 带 `DISTRIBUTED BY` | 声明分桶位的目标放过;未声明的抛 `Create table should not contain distribution desc`;`elasticsearch` 仍抛 `could not support distribution clause` | **完全没有覆盖,必须新增** | + +新增的两条要覆盖「两条检查的相对顺序」:构造一个同时带 `PARTITION BY` 和 `DISTRIBUTED BY` 的建表信息,打到一个两位都不声明的目标上,断言**先**报分桶文案。本仓库出过把连接器内多阶段校验合并后丢掉优先级、导致建表用例失败的事故,这条顺序断言就是防它复发的。 + +**变异验证**(每条新增用例都要做一次,确认它真的会失败):把连接器的分区位声明去掉 → 分区放过用例必须失败;把 `analyzeEngine` 里两条检查换个顺序 → 顺序用例必须失败;把建表声明改成返回空 → 补引擎名用例必须失败。 + +**第二步:端到端回归(本任务的必需项,不是兜底项)。** + +改动会经过分析期的四道门,任何文案漂移都会被现成用例抓到,必须实跑: + +- `regression-test/suites/external_table_p0/iceberg/write/test_iceberg_create_table.groovy` —— `:60-62` 外部目录用 `ENGINE=olap`、`:64-72` 两条错误引擎名、`:74-76` 省略 ENGINE 与正确 ENGINE 的成功建表、以及 `ORDER BY` 一组(顺带回归第 5.1 节提到的既有能力位范式)。 +- `regression-test/suites/external_table_p0/hive/ddl/test_hive_ddl.groovy` —— `:720-735` 的错误引擎名一组;`:342` / `:552` / `:599` 等 `PARTITION BY LIST` 成功建表;`:437` 与 `:460` 两处带 `DISTRIBUTED BY HASH(...) BUCKETS 16` 的建表——**注意这两处都是期望抛异常的否定用例,不是成功建表**:`:437` 那条写了 `ENGINE=olap`,期望 `Cannot create olap table out of internal catalog...`(`:442`);`:460` 那条期望 `Create hive bucket table need set enable_create_hive_bucket_table to true`(`:465`),拒绝发生在 `analyzeEngine` 之后的 hive 建表阶段,所以它只能**间接**证明 `analyzeEngine` 放行了分桶子句。 + + **分桶子句今天没有正向端到端护栏,本任务必须自己补一条。** 已实测:全仓 `regression-test` 里 `enable_create_hive_bucket_table` 只有上面那一处否定断言,插件目录上「带分桶子句成功建表」的用例一个都不存在(其余 `DISTRIBUTED BY` 命中全部是内部目录的 olap 表)。所以搬迁 `analyzeEngine` 的分桶允许列表时,**不能指望现成用例兜底**:必须新增一条 hive 目录上打开 `enable_create_hive_bucket_table` 后带 `DISTRIBUTED BY HASH(...) BUCKETS N` 成功建表并能写入读回的用例。它是本任务唯一能证明「分桶位声明真的放行了这条子句」的端到端证据;本地无集群时不能因此跳过——跑不了就必须在提交说明里写明该用例已写好但未跑,不要当它通过。作为兜底,第一步单测里那条「声明分桶位的目标放过」的用例必须同批加上(见上表最后一行)。 +- `regression-test/suites/external_table_p0/hive/ddl/test_hive_ctas.groovy` —— CTAS 入口的补引擎名路径。 +- `regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_partitions.groovy:35` 起 —— iceberg 分区建表。 +- paimon 侧带建表的用例(`regression-test/suites/external_table_p0/paimon/` 下 `test_paimon_table.groovy`、`test_paimon_schema_metadata_atomicity_matrix.groovy` 等)。 +- `regression-test/suites/external_table_p2/maxcompute/test_max_compute_create_table.groovy` —— maxcompute 是唯一同时声明两个能力位的连接器;这套用例在第二档环境,跑不了就必须在提交说明里写明未跑,不要默认它通过。 + +**第三步:显式列出并逐条确认「本来就会失败、只是文案变了」的边角组合。** 把判据从引擎名换成目标目录之后,下面这些组合的报错文案会变(它们改前改后都是拒绝,只是拒绝的位置提前了),动手前要逐条实测确认没有用例依赖旧文案: + +- 内部目录里写 `ENGINE=hive` 且带 `PARTITION BY`:原本先过 `analyzeEngine`、由 `InternalCatalog` 抛「不能在内部目录建 hive 表」,改后在 `analyzeEngine` 就抛分区文案。 +- jdbc 目录里写 `ENGINE=hive` 且带 `PARTITION BY`:原本按引擎名放过分区、到连接器才失败,改后在 `analyzeEngine` 抛分区文案。 +- 已下线的 `doris` / `test` 类型外部目录(`CatalogFactory.java:141-153`,非插件目录):不带 `ENGINE` 时仍应抛 `Current catalog does not support create table`。 + +若某条组合被现成用例断言了旧文案,就地把该条改成「保留原判据」而不是硬改文案——本任务的目标是搬迁判据,不是改用户可见行为。 + +**第四步:编译门禁。** 全反应堆**含测试源**的 `test-compile` 通过(绝对路径 `-f`,禁用任何跳过测试编译的参数),这是最强的单一符号级信号。跑单测时必须禁用 maven build cache,否则 surefire 会被静默跳过而 `BUILD SUCCESS` 是空的。删除 `pluginCatalogTypeToEngine` 后对该符号名全仓复扫应为零命中(注意 `PluginDrivenExternalTable` 与测试注释里都提到过它,要一并改掉说法)。 + +## 七、风险与回退 + +**风险一:文案漂移。** 四道门共十余条用户可见文案,端到端用例只覆盖了其中一部分。缓解手段就是第六节的「先固化再改」,以及第三步那张边角组合清单——不要靠「跑一遍绿了」当结论,要能说出每条文案在改后由谁抛出。 + +**风险二:校验顺序被打乱。** 四道门的顺序(补名 → 名字合法 → 与目录匹配 → 子句允许)和 `analyzeEngine` 内部「先分桶后分区」的顺序都承载了具体文案。本仓库已有先例:把多阶段校验合并后丢掉优先级,导致建表用例失败。要求:不合并任何两道门,`analyzeEngine` 内两条 `throw` 位置不互换,并用一条同时触发两条检查的单测把顺序钉死。 + +**风险三:把目录初始化时机提前。** `getConnector()` 会强制初始化目录。若在 `paddingEngineName`(`:413`,四道门里最早的一道)直接用它,一个远端不可达的目录上 `CREATE TABLE` 的报错会从引擎名相关文案变成连接失败。缓解:按 5.1 用不强制初始化的读法,只在连接器字段为空时才初始化。要专门测一条:连接器为空的降级目录上建表,报错仍指向「插件未加载」。 + +**风险四:插件与引擎版本错配。** 新增的默认方法与两个枚举值都是向后兼容的(旧连接器不覆写=不支持建表,与今天的 jdbc/es 行为一致),但反过来不成立:**新连接器包配旧 FE** 会因为枚举值不存在而在读取能力集时炸类加载。这与本仓库既有的插件版本纪律一致,要在提交说明里写明「连接器插件包与 FE 必须同批部署」,并做一次插件包重部署冒烟。 + +**回退**:改动集中在一个 `fe-core` 文件加四个连接器的少量声明,没有持久化格式、没有 thrift 有线格式、没有新增用户可见语法,直接整体 revert 即可,无需数据迁移。分两个提交做更稳:第一个提交只加测试(此时全绿),第二个提交搬迁判据;出问题只回退第二个。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md`:第 4.1 节第(2)小节「`CREATE TABLE` 的四处协同改动」——本任务的来源,列出 engine 常量、type→engine switch、engine 白名单、分区/分桶允许列表这四处;同节第(1)小节是目录类型白名单的删除,与本任务同源但不同文件。附录 A 第 4 条的复核——把「四处」收窄为「三个协同编辑点」,与本文第二节一致。 +- 同一份报告第十五节的整治路线表第 10 批「建表能力下沉」——把本任务标为风险 **高**,明确要求端到端兜底且错误文案与校验优先级逐字保持;第 17.1 节「合理的本地化(不要改回去)」表格中「引擎名白名单」那一行——说明白名单本身合理、不可整体移除,只该把外部目录那一段变成连接器声明,这是本任务范围的边界依据。 +- 同目录 `tasks/07-write-down-the-design-rules.md`:能力位该放枚举还是放 provider 的分层判据;本任务新增的两个能力位属于「建表子句门」,与 `SUPPORTS_SORT_ORDER` 同族,落在枚举里。 +- `plan-doc/HANDOFF.md`:动手前先读,再对照真实代码复核本文行号。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/19-relocate-elasticsearch-specific-surface.md b/plan-doc/connector-public-interface-cleanup/tasks/19-relocate-elasticsearch-specific-surface.md new file mode 100644 index 00000000000000..98894a041f8175 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/19-relocate-elasticsearch-specific-surface.md @@ -0,0 +1,247 @@ +# 19. 把 Elasticsearch 专有的逃生门与通用扫描节点里的 ES 分支归位 + +> **优先级**:第五优先级(中立化) | **风险**:中 | **前置依赖**:无 +> **影响模块**:`fe-connector-api`(删一个方法、加一个可选能力接口)、`fe-connector-es`(承接两处 ES 逻辑)、`fe-core`(净删 ES 专有分支,改一个 REST 端点的取用方式) +> **预计改动规模**:6 个主源文件 + 2 个测试文件;新增 1 个接口文件约 40 行,`fe-core` 主源净减约 8 行、净增约 12 行中立代码,`fe-connector-es` 净增约 45 行,新增单测约 120 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +有两处 Elasticsearch 专有的东西长在了所有连接器共用的公共位置上:一个是所有连接器都继承的入口接口 `Connector` 上挂着一个只有 ES 会实现的 REST 透传方法;另一个是通用扫描节点 `PluginDrivenScanNode` 里还留着两段按 ES 格式名硬判的分支(EXPLAIN 打印 `ES terminate_after`、往 ES 专属 thrift 属性里塞 `limit`)。本任务把第一处摘成一个可选能力接口交给 ES 连接器实现,把第二处经既有的两个委派钩子搬进 ES 连接器,引擎侧只以中立的合成键提供三个事实(下推的 limit、过滤是否已全部下推、BE 每批行数)。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 REST 透传方法挂在入口接口上 + +`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/Connector.java:292-305`: + +```java + /** + * Execute a REST passthrough request against the underlying data source. + * + *

      Connectors that expose HTTP endpoints (e.g., Elasticsearch) can + * override this to proxy REST requests from FE REST APIs.

      + * ... + */ + default String executeRestRequest(String path, String body) { // :303 + throw new UnsupportedOperationException("REST passthrough not supported by this connector"); + } +``` + +全仓库核实结果(`grep -rn executeRestRequest --include=*.java`): + +- 唯一实现方:`fe/fe-connector/fe-connector-es/src/main/java/org/apache/doris/connector/es/EsConnector.java:77-80`,一行转发给 `EsConnectorRestClient.executePassthrough`; +- 唯一调用方:`fe/fe-core/src/main/java/org/apache/doris/httpv2/restv2/ESCatalogAction.java:86` 与 `:100`,两个 REST 端点 `/rest/v2/api/es_catalog/get_mapping`、`/rest/v2/api/es_catalog/search`,分别拼出 `/_mapping` 与 `
      /_search` 两个 ES 路径; +- 该端点在 `ESCatalogAction.java:67-70` 已经按类型名收窄:`!"es".equals(((PluginDrivenExternalCatalog) catalog).getType())` 就直接回 `badRequest("unknown ES Catalog: ...")`。 + +也就是说:路径与响应形状都是 ES 的,收窄判定在端点里,而方法本身却挂在每个连接器都继承的入口接口上,其他 7 个连接器继承到的是一个「调用即抛异常」的方法(本仓库共 8 个 `Connector` 实现:es / hive / hudi / iceberg / jdbc / maxcompute / paimon / trino)。 + +同一个 `Connector` 接口里已经有现成的可选能力写法(`Connector.java:339-349`),javadoc 里还明确写了不要用 `instanceof`: + +```java + /** + * Returns this connector's incremental metadata-change source, or {@code null} if it has none. + * A capability-probe getter ... never via {@code instanceof}. ... + */ + default ConnectorEventSource getEventSource() { // :347 + return null; + } +``` + +引擎侧对应的取用方式在 `fe/fe-core/src/main/java/org/apache/doris/datasource/MetastoreEventSyncDriver.java:132`(`getConnector().getEventSource()` 判空)。 + +### 2.2 通用扫描节点里的两段 ES 分支 + +文件:`fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java`。 + +第一段,EXPLAIN 输出(`:559-564`): + +```java + // Show ES terminate_after optimization when limit is pushed to ES + if (limit > 0 && conjuncts.isEmpty() + && "es_http".equals(props.get(PROP_FILE_FORMAT_TYPE))) { + output.append(prefix).append("ES terminate_after: ") + .append(limit).append("\n"); + } +``` + +第二段,往 thrift 参数里塞 ES 属性(`:1815-1836`): + +```java + public void createScanRangeLocations() throws UserException { + super.createScanRangeLocations(); + ConnectorScanPlanProvider scanProvider = resolveScanProvider(); + if (scanProvider != null) { + Map props = getOrLoadScanNodeProperties(); + onPluginClassLoader(scanProvider, () -> { + scanProvider.populateScanLevelParams(params, props); // :1823 + return null; + }); + } + pruneConjunctsFromNodeProperties(); // :1827 + // Push down limit to ES via terminate_after optimization. ... + if (limit > 0 && limit <= sessionVariable.batchSize && conjuncts.isEmpty() + && params.isSetEsProperties()) { // :1832-1833 + params.getEsProperties().put("limit", String.valueOf(limit)); + } + } +``` + +几个已核实的关键事实: + +1. `"es_http"` 这个格式名是 ES 连接器自己写进扫描节点属性的(`EsScanPlanProvider.java:184` 的 `nodeProps.put("file_format_type", "es_http")`,以及 `EsScanRange.java:91`); +2. `es_properties` 里的 `"limit"` 键是 BE 契约:`be/src/format/table/es/es_scan_reader.h:46` 的 `KEY_TERMINATE_AFTER = "limit"`,被 `es_scan_reader.cpp:79` 与 `es_scroll_query.cpp:123` 读取,拼成 ES 的 `terminate_after=` 查询串。这个字符串不能改; +3. `params.setEsProperties(...)` 本来就是 ES 连接器自己做的(`EsScanPlanProvider.java:371`),也就是引擎在 `:1834` 是往连接器刚设进去的那张 map 里补写一个键; +4. 两个承接钩子都已存在并已被 ES 实现:`ConnectorScanPlanProvider.java:474 populateScanLevelParams` / `:490 appendExplainInfo`,ES 侧实现在 `EsScanPlanProvider.java:362` 与 `:410`; +5. 引擎向连接器传递「引擎侧事实」的合成键机制已有先例:`PluginDrivenScanNode.java:132-146` 定义了 `__native_read_splits` / `__total_read_splits` / `__explain_verbose` 三个键,`:538-543` 在委派前拷一份属性 map 注入,paimon 连接器按字面量消费并有单测锚定(`PaimonScanExplainTest.java:63` 等); +6. **`conjuncts.isEmpty()` 的时机是有讲究的**:`:1832` 读到的 `conjuncts` 是 `:1827` 剪枝**之后**的,剪枝逻辑(`pruneConjunctsFromNodeProperties`,`:1873-1882`)会把因为含 CAST 而从未交给连接器的谓词**保留**下来,所以「conjuncts 空」等价于「所有过滤都真的被连接器接走了」。这是正确性判据,不是顺手写的条件; +7. 同一个文件在 `:490-499` 自己写下了这条规则:`NO source-name branch belongs in this generic node ... Connector-SPECIFIC EXPLAIN stays delegated to ConnectorScanPlanProvider.appendExplainInfo`。 + +## 三、为什么这是个问题 + +**违反的原则**:通用接口层与通用扫描节点里禁止出现数据源专有代码。第二处尤其明显——违规代码和写着「这里不许有源名分支」的注释在同一个文件里相隔 60 行。 + +**真实后果**: + +- 通用节点里硬编码了某个连接器的格式标签 `"es_http"` 与某个连接器的 thrift 字段 `es_properties`。任何一个新连接器想要「LIMIT 提示」这类能力,今天都必须回到 `fe-core` 加一段 `"xxx_http".equals(...)` 分支——这正是本次整治要终结的模式; +- 一半判据在引擎、一半渲染在引擎,导致两半已经不一致:EXPLAIN 那段(`:560`)**没有** `limit <= sessionVariable.batchSize` 这个判据,而真正下推那段(`:1832`)有。于是当 `batch_size` 小于 LIMIT 时,用户在 EXPLAIN 里看到 `ES terminate_after: 5000`,实际却没有任何 `limit` 被发给 BE。这是既有缺陷,不是本任务引入的(本任务按原样搬迁,见第七节与最后的待拍板问题); +- `Connector` 那个方法让 7 个与 ES 无关的连接器都继承到一个「一调用就抛 `UnsupportedOperationException`」的方法,能力的有无只能靠异常发现,而不是像 `getEventSource()` 那样能判空探测。 + +## 四、用一个最小例子说明 + +一条最普通的 ES 查询: + +```sql +select * from es_catalog.my_db.my_index limit 5; +``` + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +|---|---|---| +| `... limit 5`,然后看 `EXPLAIN` | 通用扫描节点先问连接器要 ES 的那几行(`ES index:` 等),随后自己判断 `file_format_type == "es_http"`,再自己打印 `ES terminate_after: 5` | 引擎只告诉连接器两个中立事实:这个扫描的 LIMIT 是 5、过滤已全部下推;`ES terminate_after: 5` 这一行由 ES 连接器自己打印 | +| `... limit 5` 真正执行 | ES 连接器先把 `es_properties` 设进 thrift,引擎随后往这张 map 里补一个 `limit=5` | ES 连接器在设 `es_properties` 时自己决定要不要加 `limit=5`(引擎额外告诉它 BE 每批行数是多少) | +| `curl '.../rest/v2/api/es_catalog/get_mapping?catalog=es_catalog&table=my_index'` | 端点先判 `type == "es"`(保留),再调所有连接器都继承的 `Connector.executeRestRequest` | 端点仍先判 `type == "es"`,再取 `getRestPassthrough()`,只有 ES 连接器返回非空 | + +再换个角度:**假设我要新增一个连接器 X,它也想在 EXPLAIN 里报告自己把 LIMIT 下推给了远端**。今天我必须打开 `fe-core` 的 `PluginDrivenScanNode`,在 `:559` 旁边加一段 `"x_native".equals(props.get("file_format_type"))` 的分支;改完之后,`fe-core` 就多知道了一个连接器的格式名。本任务做完之后,我什么公共代码都不用碰:三个合成键对每个连接器都注入,我在自己的 `appendExplainInfo` 里读就行。 + +## 五、解决方案 + +### 5.1 目标状态 + +**(一)REST 透传变成可选能力接口。** 新增 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/rest/ConnectorRestPassthrough.java`: + +```java +/** A connector that can proxy an HTTP REST request to its underlying source. */ +public interface ConnectorRestPassthrough { + /** + * @param path 相对路径(由调用端按该数据源的 REST 形状拼好) + * @param body 请求体,GET 风格请求可为 null + * @return 响应体原文 + */ + String executeRestRequest(String path, String body); +} +``` + +`Connector.java` 删掉 `executeRestRequest`(`:292-305`),换成与 `getEventSource()` 同形的能力探针: + +```java + /** + * Returns this connector's REST passthrough capability, or {@code null} if it has none. + * A capability-probe getter (mirrors {@link #getEventSource()}): the caller probes for null, + * never via {@code instanceof}. Default null, so no connector inherits a throwing method. + */ + default ConnectorRestPassthrough getRestPassthrough() { + return null; + } +``` + +`EsConnector` 改成 `implements Connector, ConnectorRestPassthrough`,保留原来的一行方法体,另加 `getRestPassthrough() { return this; }`。 + +`ESCatalogAction` 把内部 `handleRequest` 的函数参数从 `BiFunction` 换成 `BiFunction`,在既有的 `"es"` 类型判定之后取一次能力、判空后再调;两个端点的 lambda 从 `((PluginDrivenExternalCatalog) catalog).getConnector().executeRestRequest(...)` 简化为 `rest.executeRestRequest(...)`。**`"es".equals(...)` 那句一个字不动。** + +**(二)扫描节点的两段 ES 分支搬进 ES 连接器。** `PluginDrivenScanNode` 新增三个合成键(形态、注释风格照抄 `:132-146`): + +```java + private static final String PUSHDOWN_LIMIT_KEY = "__pushdown_limit"; + private static final String ALL_CONJUNCTS_PUSHED_KEY = "__all_conjuncts_pushed"; + private static final String SESSION_BATCH_SIZE_KEY = "__session_batch_size"; +``` + +两条委派路径都注入这三个键(EXPLAIN 路径在 `:538` 的 `explainProps` 上追加;thrift 路径新拷一份 map)。为此 `createScanRangeLocations` 里必须把 `pruneConjunctsFromNodeProperties()` 提到 `populateScanLevelParams` 委派**之前**,因为「过滤是否已全部下推」这个事实只有剪枝后才成立。 + +顺序调换的安全性依据(已逐个核实):现存五个 `populateScanLevelParams` 实现——`HiveScanPlanProvider.java:449`、`HudiScanPlanProvider.java:409`、`IcebergScanPlanProvider.java:1777`、`PaimonScanPlanProvider.java:1355`、`EsScanPlanProvider.java:362`——都只按固定键读传入的属性 map 并写 `params`,没有任何一个读 `conjuncts`;而 `pruneConjunctsFromNodeProperties` 只改 `conjuncts` 并读已缓存的属性结果。因此交换顺序对 thrift 参数逐字节不变。 + +ES 连接器侧:`EsScanPlanProvider.populateScanLevelParams` 在把 `esProperties` 设进 params 之前,按 `limit > 0 && batchSize > 0 && limit <= batchSize && "true".equals(allPushed)` 决定是否 `esProperties.put("limit", ...)`;`appendExplainInfo` 在现有三行 ES 输出之后,按 `limit > 0 && "true".equals(allPushed)` 追加 `ES terminate_after: `(**故意不加 batch 判据**,逐字保留既有行为,并写 ATTN 注释说明这一不对称是既有状态)。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe-connector-api/.../connector/api/rest/ConnectorRestPassthrough.java` | **新增**。单方法可选能力接口,javadoc 中立、不点名任何数据源 | +| `fe-connector-api/.../connector/api/Connector.java` | 删 `:292-305` 的 `executeRestRequest`;加 `getRestPassthrough()` 默认返回 null(放在 `getEventSource()` 附近,注释对齐其写法) | +| `fe-connector-es/.../es/EsConnector.java` | `implements Connector, ConnectorRestPassthrough`;保留 `:77-80` 方法体并加 `@Override`;新增 `getRestPassthrough() { return this; }` | +| `fe-core/.../httpv2/restv2/ESCatalogAction.java` | `handleRequest` 的函数参数类型换成 `ConnectorRestPassthrough`;`:71` 之后探测能力、为 null 返回 `badRequest`;两个 lambda 去掉重复的 catalog 强转。**不动 `:67-70` 的类型判定** | +| `fe-core/.../datasource/scan/PluginDrivenScanNode.java` | 新增三个合成键常量;`:538-543` 的 `explainProps` 追加注入;删 `:559-564` 整段 ES EXPLAIN 分支;`createScanRangeLocations` 把剪枝提前、委派时传注入后的属性副本、删 `:1829-1835` 整段 ES limit 分支;抽一个包内可见的纯静态注入辅助方法便于单测 | +| `fe-connector-es/.../es/EsScanPlanProvider.java` | 新增三个与引擎侧逐字节相同的键常量;`populateScanLevelParams` 承接 `limit` 写入;`appendExplainInfo` 末尾承接 `ES terminate_after` 一行 | +| `fe-connector-es/src/test/.../EsScanPlanProviderTest.java` | 新增 5 个用例(见第六节) | +| `fe-core/src/test/.../datasource/scan/` 下新增一个测试类 | 断言合成键注入辅助方法的取值映射(照 `PluginDrivenScanNodeLimitStripTest` / `PluginDrivenScanNodeExplainStatsTest` 的纯静态断言写法) | + +### 5.3 明确不要顺手做的事 + +- **不要中立化 `ESCatalogAction` 里的 `"es".equals(...)` 判定。** 这个端点本身就是 ES 兼容 API(路径拼 `/_mapping`、`/_search`,响应形状是 ES 的),按类型收窄是正确边界;一旦中立化,就等于把这个端点开给所有声明了 REST 直通能力的连接器,白扩攻击面。 +- **不要动 `mapFileFormatType` 里的 `case "es_http": return TFileFormatType.FORMAT_ES_HTTP;`(`PluginDrivenScanNode.java:1970`)。** 那是格式名到 thrift 枚举的通用查表,和 `parquet` / `orc` / `text` 并列,thrift 枚举还受有线格式约束;它不是源专有分支。 +- **不要顺手修 EXPLAIN 与实际下推的 batch 判据不一致**(第三节第二条)。本任务的验收基线是 EXPLAIN 文本逐字不变;要修就另立一项,同时改 `external_table_p2` 的用例并想清楚 `batch_size` 变化时的用户预期。 +- **不要顺手给 `ESCatalogAction` 补插件类加载器钉扎**。当前 REST 线程直接调进插件、未 pin TCCL,这是既有状态,与本任务无关;要修单独立项评估。 +- **不要动 `pruneConjunctsFromNodeProperties` 的剪枝算法本身**(含 CAST 谓词保留那段),本任务只调它的调用位置。 +- **不要为「通用节点里不许出现源名」这条不变量加 shell/正则门禁**。本仓已有结论:这类门禁只适合存在性与前缀类不变量,判断语言语义时误报比漏报更毒。用注释 + 单测 + 评审。 + +## 六、怎么验证 + +**ES 连接器单测**(`EsScanPlanProviderTest`,现有 `testAppendExplainInfoShowsEsIndex` 就是模板): + +1. `populateScanLevelParams`:属性含 `__pushdown_limit=5`、`__session_batch_size=1024`、`__all_conjuncts_pushed=true` → `params.getEsProperties().get("limit")` 等于 `"5"`。这条同时锚定 BE 契约字符串 `"limit"`; +2. `__all_conjuncts_pushed=false` → `es_properties` 里**没有** `limit` 键(这是正确性用例:过滤没全下推却限量,会少返回行); +3. `__pushdown_limit=5000`、`__session_batch_size=1024` → 没有 `limit` 键; +4. 三个键都缺失(老调用方/纯单测 map)→ 没有 `limit` 键; +5. `appendExplainInfo`:`__pushdown_limit=5` + `__all_conjuncts_pushed=true` → 输出**逐字**含 `ES terminate_after: 5\n`(前缀、冒号后一个空格);`__all_conjuncts_pushed=false` 或键缺失 → `notContains "ES terminate_after"`。 + +**变异验证**(改实现应让上述用例变红):把用例 1 的比较写成 `limit >= batchSize` → 用例 3 失败;去掉 `__all_conjuncts_pushed` 判据 → 用例 2、5 的负例失败;把 `"limit"` 键名写成 `"terminate_after"` → 用例 1 失败。 + +**引擎侧单测**(新增类,照 `PluginDrivenScanNodeLimitStripTest` 的纯静态写法):断言注入辅助方法在 `limit=-1` / `limit=5`、`allPushed` 真假、`batchSize` 各取值下写出的键值字符串正确,且键名字面量与 ES 连接器侧一致(两侧各写死同一批字面量,这与 paimon 现有合成键的做法同构)。剪枝先于委派这一顺序无法用纯静态方法覆盖,用 ATTN 注释写清依据(5.1 节列的五个实现均不读 `conjuncts`),交评审把关。 + +**编译门禁**(最强单一信号,必须全反应堆含测试源): + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T 1C test-compile +``` + +不得加任何跳过测试编译的参数。这一步能一次性抓出「有没有别的模块还在调 `Connector.executeRestRequest`」。 + +**跑单测**(必须禁用构建缓存,否则 surefire 会被静默跳过): + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -Dmaven.build.cache.enabled=false \ + -pl fe-connector/fe-connector-es,fe-core -am test \ + -Dtest='EsScanPlanProviderTest,PluginDrivenScanNode*Test' +``` + +**端到端回归**:`regression-test/suites/external_table_p2/es/test_es_query_predicate_correctness.groovy:94-135` 六处断言(`contains "ES terminate_after: 5"` / `"3"`、`notContains "ES terminate_after"`,ES 7 与 ES 8 各三处)必须仍通过。这些断言用 `contains`,对行的相对位置不敏感——本任务会让 `ES terminate_after` 从 `pushdown agg=` 之后移到 ES 其它几行之后(即 `pushdown agg=` 之前)。已核实仓库内没有把整段 EXPLAIN 存成 golden 文件的 ES 用例(`grep -rn terminate_after regression-test --include=*.out` 无命中),所以只有这一个 p2 套件受影响。该套件需要 ES 7/8 容器,本地无集群时须显式标注为「待集群验证」,不得当作已通过。 + +**REST 端点手工验证**(无自动化覆盖,`ESCatalogAction` 在仓库里既无单测也无回归用例):部署后各调一次 `get_mapping` 与 `search`,确认响应体与改动前一致;另外用一个非 ES 目录名调一次,确认仍返回 `unknown ES Catalog`。 + +## 七、风险与回退 + +- **EXPLAIN 文本漂移(主要风险)**:`ES terminate_after: ` 这个前缀连空格都不能变,否则 p2 用例失败。对策是连接器单测断言整行,且搬迁时用复制粘贴而不是重写字符串拼接。 +- **行位置变化**:如上所述,这一行在 EXPLAIN 里的位置会前移。当前所有断言都是包含式,风险可接受;但若后续有人加了整段比对,会暴露。 +- **剪枝与委派顺序调换**:依据已在 5.1 列出(五个实现都不读 `conjuncts`)。若担心,可分两个 commit:先只搬 EXPLAIN 那半(不需要调顺序),确认 p2 通过后再搬 thrift 那半。 +- **合成键字符串两侧漂移**:引擎与 ES 各自定义常量,靠单测里硬编码字面量锚定——与 paimon 现有三个合成键同风险同对策。 +- **新旧版本混搭部署**:删掉 `Connector.executeRestRequest` 后,若用旧的 ES 插件包配新 `fe-core`,插件里的 `EsConnector` 多出一个不再属于任何接口的方法(运行期无害),但 `getRestPassthrough()` 会返回 null,两个 REST 端点会返回 `badRequest` 而不是原来的 500。插件与 FE 同版本部署是既有约定,此处只需在提交说明里写明。 +- **一个需要用户拍板的取舍**:EXPLAIN 那半缺少 `limit <= batch_size` 判据(第三节第二条),会让用户在 `batch_size` 较小时看到一个并未真正生效的 `ES terminate_after`。本文默认**逐字保留**这个不一致(验收基线是文本不变);若允许在同一轮里把连接器侧 EXPLAIN 判据补齐成与实际下推一致,则需接受 `batch_size` 小于 LIMIT 时 EXPLAIN 输出发生变化(现有 p2 用例的 `limit 5` / `limit 3` 远小于默认 `batch_size`,不会变红)。 +- **回退**:三处改动彼此独立(REST 能力接口 / EXPLAIN 一行 / `es_properties` 的 `limit`)。建议至少分两个 commit:一个只做 REST 能力接口,一个只做扫描节点归位,便于单独 revert。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md`:附录 A.2「公共模块里的数据源专有语义」下第 18 条——通用扫描节点里的 ES 分支(EXPLAIN 的 `terminate_after` 与 `esProperties` 的 limit),严重度高;第 19 条——`executeRestRequest` 这个 ES 专用逃生门,严重度中。同一报告第 8.1 节的清单表格里 `Connector.executeRestRequest` 那一行明确写了「那个 REST 端点自己的类型判定不动」。 +- `plan-doc/connector-api-spi-design-review-2026-07-25.md:159`、`:193`、`:646`:把 REST 透传与 SQL 透传一并移出通用入口接口的原始建议(本任务只做 REST 那半;SQL 透传 `executeStmt` + `getColumnsFromQuery` 不在本任务范围)。 +- 合成键先例:`PluginDrivenScanNode.java:132-146`(定义)、`:538-543`(注入)、`PaimonScanPlanProvider.java:1393` 起(消费)、`PaimonScanExplainTest.java`(按字面量锚定的单测)。 +- 能力探针先例:`Connector.java:339-349`(`getEventSource`,javadoc 明确「never via instanceof」)、`MetastoreEventSyncDriver.java:56` 与 `:132`(引擎侧判空取用)。 +- 通用节点里禁止源名分支的规则原文:`PluginDrivenScanNode.java:470-472` 与 `:490-499`。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/20-neutralize-null-partition-sentinel.md b/plan-doc/connector-public-interface-cleanup/tasks/20-neutralize-null-partition-sentinel.md new file mode 100644 index 00000000000000..73d942c0a710f8 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/20-neutralize-null-partition-sentinel.md @@ -0,0 +1,217 @@ +# 20. 分区空值哨兵的中立化命名与归一方法下沉 + +> **优先级**:第五优先级(中立化) | **风险**:低 | **前置依赖**:无 +> **影响模块**:`fe-connector-api`、`fe-connector-hudi`、`fe-connector-hive`、`fe-connector-paimon`、`fe-core`(主源只做「一个重复常量定义换成引用」的净删,测试源改名引用) +> **预计改动规模**:约 10~12 个文件;公共模块净删约 35 行、hudi 净增约 12 行、fe-core 净删约 1 行;新增 2 个单测文件约 90 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +面向所有连接器的中立公共模块 `fe-connector-api` 里,唯一一个带数据源品牌的字符串常量叫 `HIVE_DEFAULT_PARTITION`,旁边还挂着三个名字通用、语义却只对 hive 与 hudi 的目录式分区成立的归一方法(`normalize` / `isNullPartitionValue` / `normalizePartitionValue`)——hive 和 paimon 都在注释里明确写了「不要走这些方法」。本任务把常量改成中立名字(**字符串值一个字节都不动**),把三个只有 hudi 一个生产调用方的方法下沉进 hudi 连接器,并把引擎里那份重复的同串定义改成引用公共常量。 + +## 二、背景:现在的代码是怎么写的 + +**公共模块本体**:`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorPartitionValues.java`,整个文件只有 73 行,**没有类级 javadoc**: + +```java +public final class ConnectorPartitionValues { // :24 + public static final String HIVE_DEFAULT_PARTITION = "__HIVE_DEFAULT_PARTITION__"; // :26 + public static final String NULL_PARTITION_VALUE = "\\N"; // :27 + + public static Normalized normalize(List partitionValues) { ... } // :32 + public static boolean isNullPartitionValue(String value) { // :46 + return value == null || HIVE_DEFAULT_PARTITION.equals(value) + || NULL_PARTITION_VALUE.equals(value); + } + public static String normalizePartitionValue(String value) { ... } // :51 + public static final class Normalized { ... } // :56 +} +``` + +**谁在用这个常量(只用常量、不用方法)**: + +| 位置 | 干什么 | +|---|---| +| `fe-core` `FilePartitionUtils.java:143`(import 在 :21) | 引擎自己按 hive 风格目录名解析分区列:`boolean isNull = ConnectorPartitionValues.HIVE_DEFAULT_PARTITION.equals(pair[1]);` | +| `fe-connector-hive` `HiveScanRange.java:173` | 只做窄比较(`value == null || 常量.equals(value)`),注释 :159-161 明确写「用窄比较,**不要**用 `ConnectorPartitionValues.normalize`,它会把字面量 `\N` 也判成空」 | +| `fe-connector-hive` `HiveConnectorMetadata.java:1206` | 生成分区空值标志,注释 :1198-1200 同样点名「不用更宽的 `isNullPartitionValue`」 | +| `fe-connector-paimon` `PaimonConnectorMetadata.java:1263` | **反过来**:paimon 把自己的空分区名(`partition.default-name`,默认 `__DEFAULT_PARTITION__`)**主动归一到这个常量**,注释 :1260-1261 自陈「名字归一到 Doris 规范哨兵」 | +| `fe-connector-paimon` `PaimonScanRange.java:281-283` | 注释里第三次明确绕开 `normalize` | + +**谁在用那三个方法**:整仓库只有一处生产调用点,`fe-connector-hudi` `HudiScanRange.java:247-248`: + +```java +ConnectorPartitionValues.Normalized normalized = ConnectorPartitionValues.normalize(pathValues); +rangeDesc.setColumnsFromPathKeys(pathKeys); +rangeDesc.setColumnsFromPath(normalized.getValues()); +rangeDesc.setColumnsFromPathIsNull(normalized.getIsNull()); +``` + +`NULL_PARTITION_VALUE`(`\N`)在类外零引用;`isNullPartitionValue` / `normalizePartitionValue` 也只有类内调用方;`fe-connector-api` 的测试目录里没有任何一个用例覆盖这个类。 + +**引擎侧还有第二份同串定义**:`fe-core` `TablePartitionValues.java:47` 又写了一遍 `public static final String HIVE_DEFAULT_PARTITION = "__HIVE_DEFAULT_PARTITION__";`。它是活的:本类 :162 用它决定 `PartitionValue` 的空值位(经 `PluginDrivenExternalTable.java:858` 的 `addPartitions` 走活路径),`MetadataGenerator.java:2067` 用它把 `partition_values()` 表函数的这一格渲染成 SQL NULL。这个类没有任何 Gson 注解,不涉及持久化格式。 + +**为什么这个字符串值不能改**:`test_hive_partition_values_tvf.groovy:66` 与 `:73` 直接在 SQL 里写 `where t_int != "__HIVE_DEFAULT_PARTITION__"`,且 :71 建了一个持久化的 internal 视图;`test_paimon_mtmv.groovy:272` 的注释记录了物化视图曾按 `region IN ('__HIVE_DEFAULT_PARTITION__')` 刷新。分区名一旦进了视图定义、物化视图分区、以及 BE 的 `columns_from_path` 字节,就是对外可见的持久化标识。 + +**另外一件容易搞混的事**:`ConnectorPartitionInfo` 的分区空值标志(结构化布尔位)与这个字符串哨兵**不是替代关系**。`PluginDrivenMvccExternalTable.java:308-323` 的 javadoc 已经写清了分工:标志服务于 FE 侧构造带类型的 `NullLiteral`(否则 INT / DATE 分区列会在解析哨兵字符串时抛异常、整个分区被静默丢弃,表被误报成未分区),哨兵服务于分区名身份与 BE 列路径解析的字节兼容。同一段 javadoc 还点明「hive 与 paimon 渲染出**同一个**哨兵字符串但空值语义不同,所以 fe-core 不能靠字符串比较判空」。 + +## 三、为什么这是个问题 + +三件事,都属于「命名与归属」层面,不是正确性缺陷: + +1. **中立模块里挂着数据源品牌名**。`fe-connector-api` 是给所有连接器(包括第三方自研连接器)看的公共契约面,`HIVE_DEFAULT_PARTITION` 是里面唯一一个带数据源品牌的字符串常量。一个写 paimon 连接器的人被迫引用一个叫「hive 默认分区」的常量来表达「Doris 规范的空分区名」,读代码的人会以为自己走错了模块。 + +2. **名字通用、语义专有的方法会把人骗进坑里**。`isNullPartitionValue(String)` 这个签名看不出任何限定,实际语义是「hive/hudi 的目录式分区里,`null`、`__HIVE_DEFAULT_PARTITION__`、字面量 `\N` 三者都算空」。对类型化分区值的连接器(paimon 的分区值本来就是 Java 类型,`\N` 是合法的字符串数据)用它就会**把真实数据判成 NULL**。现状是靠三处注释(`HiveScanRange.java:159-161`、`HiveConnectorMetadata.java:1198-1200`、`PaimonScanRange.java:281-283`)挡住的——需要三条注释反复警告「别用公共方法」,说明这个方法放错了地方。下一个连接器作者不会先读别人的注释。 + +3. **同一个字面量三处可写、两处已写**。公共模块和 `fe-core` 各有一份活定义,谁改一处都不会让另一处编译失败。这也是为什么**不能**把常量下沉到 hive 连接器:引擎自己的 `FilePartitionUtils` 在用它(引擎不能 import 插件),非 hive 的 paimon 也在主动往它归一,下沉的结果是从两份变三份。 + +## 四、用一个最小例子说明 + +假设我要新增一个连接器 X,它的分区值是类型化的(跟 paimon 一样),空分区的目录名是 ``,而且它有一列的真实数据里就存着字符串 `\N`。 + +| 我今天读到什么 | 我大概率会怎么写 | 实际发生什么 | +|---|---|---| +| 中立公共模块里有 `ConnectorPartitionValues.normalize(values)`,名字通用,附近没有任何限定说明 | 直接调它,一次拿到值列表和空值标志列表 | 那一行真实数据 `\N` 被判成 SQL NULL,查询静默少行;要发现这点,得先读到 hive 和 paimon 源码里的三条「别用它」注释 | +| 我想表达「这个分区是空值」的规范分区名 | 找不到中立名字,只能 `import ...HIVE_DEFAULT_PARTITION` | 我的连接器里出现了一个 hive 品牌常量,评审要花时间解释「这不是 hive 专用」 | + +改完之后:公共模块里只剩一个中立命名的常量(值不变),旁边有一句 javadoc 说明它是「Doris 规范的空分区名、字节冻结」;那个会误伤的 `normalize` 不再出现在公共面上,它连同 `\N` 语义一起留在唯一真正需要它的 hudi 连接器里。连接器 X 的作者拿不到那把误伤的刀,只能按自己的类型化语义自己填空值标志——这正是 hive、paimon、iceberg 现在各自的做法。 + +## 五、解决方案 + +### 5.1 目标状态 + +公共模块只保留常量,签名草案: + +```java +/** + * Doris 规范的分区名相关常量。 + * + *

      NULL_PARTITION_NAME 是「这个分区列的值是真正的 NULL」在**分区名**里的规范写法。它的字面量 + * 沿用 hive 的历史取值,且**必须逐字冻结**:分区名会进入视图 / 物化视图定义、partition_values() + * 表函数结果,以及 BE 的 columns_from_path 字节。连接器若有自己的空分区名(如 paimon 的 + * partition.default-name),应在渲染分区名时归一到本常量。 + * + *

      注意:本常量不能替代 ConnectorPartitionInfo 的分区空值标志。标志服务于 FE 侧构造带类型的 + * NullLiteral,哨兵服务于分区名身份;「值是否为空」必须由连接器用标志声明,fe-core 不做字符串比较。 + */ +public final class ConnectorPartitionValues { + + public static final String NULL_PARTITION_NAME = "__HIVE_DEFAULT_PARTITION__"; + + /** @deprecated 改用 {@link #NULL_PARTITION_NAME};本别名仅为外部连接器保留一轮。 */ + @Deprecated + public static final String HIVE_DEFAULT_PARTITION = NULL_PARTITION_NAME; + + private ConnectorPartitionValues() { + } +} +``` + +hudi 一侧:`normalize` / `Normalized` / `\N` 常量全部消失,改成 `HudiScanRange.populateRangeParams` 里的一段内联循环(与 hive、paimon、iceberg 三家现在的写法一致,不新增类、不新增抽象): + +```java +private static final String HUDI_NULL_PARTITION_VALUE = "\\N"; // hudi 目录式分区的空值渲染,保持发给 BE 的字节不变 +... +String value = entry.getValue(); +// hudi 的分区值来自路径目录名:Java null、规范空分区名、以及历史上的 "\N" 都算空。 +// 这三条只对目录式分区成立,所以留在 hudi 里,不放在中立公共模块(hive 与 paimon 都刻意绕开它)。 +boolean nullValue = value == null + || ConnectorPartitionValues.NULL_PARTITION_NAME.equals(value) + || HUDI_NULL_PARTITION_VALUE.equals(value); +pathKeys.add(entry.getKey()); +pathValues.add(nullValue ? HUDI_NULL_PARTITION_VALUE : value); +pathIsNull.add(nullValue); +``` + +这段与原 `normalize` 逐字等价:原实现对 `null` 与规范空分区名都渲染成 `\N`,对字面量 `\N` 原样保留(也是 `\N`)并置空值位为 true——新代码三种情况都渲染 `\N`、空值位 true,发给 BE 的字节完全一致。注意 hudi 的空值渲染是 `\N` 而 hive / paimon / iceberg 是空串(`HiveScanRange.java:175`、`PaimonScanRange.java:287`),**本任务不统一这个差异**(见 5.3)。 + +`fe-core` 一侧:删掉 `TablePartitionValues.java:47` 那份重复定义,两个使用点改为引用公共常量。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe-connector-api/.../scan/ConnectorPartitionValues.java` | 新增中立常量 `NULL_PARTITION_NAME`(值不变);旧名保留为 `@Deprecated` 别名并指向新常量;补类级 javadoc(写清「值冻结」「与空值标志的分工」);删除 `NULL_PARTITION_VALUE`、`normalize`、`isNullPartitionValue`、`normalizePartitionValue`、嵌套类 `Normalized`(共约 35 行) | +| `fe-connector-hudi/.../HudiScanRange.java:247-251` | 用 5.1 的内联循环替换 `normalize` 调用;加 `HUDI_NULL_PARTITION_VALUE` 私有常量与 WHY 注释;`import` 保留(仍要引用中立常量) | +| `fe-connector-hive/.../HiveScanRange.java:173` | 常量引用改新名;顺手把 :160 那句「不要用 `ConnectorPartitionValues.normalize`」的注释改成指向 hudi 内部实现(否则注释指向一个已不存在的符号) | +| `fe-connector-hive/.../HiveConnectorMetadata.java:1206` | 常量引用改新名;:1200 提到 `isNullPartitionValue` 的注释同上改写 | +| `fe-connector-paimon/.../PaimonConnectorMetadata.java:1263`(及 :1245 注释) | 常量引用与注释里的符号名改新名 | +| `fe-connector-paimon/.../PaimonScanRange.java:281-283` | 注释里提到 `normalize` 的部分改写(无代码引用) | +| `fe-core/.../datasource/TablePartitionValues.java:47,162` | 删掉重复的常量定义,:162 改为引用 `ConnectorPartitionValues.NULL_PARTITION_NAME`(fe-core 主源净删一行,不新增数据源相关代码) | +| `fe-core/.../tablefunction/MetadataGenerator.java:2067` | 引用改为公共常量;去掉不再需要的 `TablePartitionValues` import(若该 import 还有别的用途则保留) | +| `fe-core` 测试:`PluginDrivenMvccExternalTableTest.java`、`ListPartitionItemTest.java` | 9 处 `TablePartitionValues.HIVE_DEFAULT_PARTITION` 引用改为公共常量(`PluginDrivenMvccExternalTableTest` 8 处:287 / 291 / 297 / 310 / 319 / 329 / 335 / 346 行;`ListPartitionItemTest` 1 处:64 行)。纯改名,断言语义不动,但处数比看上去多,别漏改 | +| `fe-connector-paimon` / `fe-connector-hive` 测试中引用旧常量名的用例 | 改新名(`PaimonConnectorMetadataPartitionTest` 5 处等;写死字面量的用例不必改) | +| **新增** `fe-connector-hudi/src/test/java/.../HudiScanRangePartitionValuesTest.java` | 见第六节 | +| **新增** `fe-connector-api/src/test/java/.../scan/ConnectorPartitionValuesTest.java` | 见第六节 | + +### 5.3 明确不要顺手做的事 + +- **不要改字符串值,也不要「顺手」把 `__HIVE_DEFAULT_PARTITION__` 改成看起来更中立的字面量。** 它是持久化标识:视图 / 物化视图定义、`partition_values()` 表函数输出、BE 列路径解析都按它对齐(证据见第二节的两个回归套件)。本任务改的只是 Java 侧的符号名。 +- **不要把常量下沉到 hive 连接器。** 引擎的 `FilePartitionUtils.java:143` 在用它,而引擎不能 import 插件;paimon 也在主动往它归一。下沉只会让同一个字面量变成三份。 +- **不要试图用结构化空值标志「取代」哨兵、也不要反向取代。** 两者服务的对象不同(第二节最后一段)。任何「统一成一套」的提议都会打破 FE 侧类型化空值或 BE 侧字节兼容中的一个。 +- **不要顺手统一 hudi 的 `\N` 与 hive / paimon 的空串。** 这两个渲染都是发给 BE 的 `columns_from_path` 值,理论上空值位为 true 时 BE 会忽略字符串,但这属于行为变更、需要端到端验证,与本次的命名与归属整治无关。要做就单独立项。 +- **不要重命名 `ConnectorPartitionValues` 这个类**(哪怕它瘦到只剩一个常量)。类改名会牵动全部连接器的 import,收益为零。 +- **不要顺手清理 `TablePartitionValues` 这个类**(它在 `PluginDrivenExternalTable.getNameToPartitionItems` 的活路径上)。本任务只删它那一行重复的常量定义。 +- **不要为「公共模块里不许出现数据源品牌字符串」写 shell 或正则门禁。** 本仓库已有结论:这类门禁只适合存在性与前缀类不变量,去校验「哪个字符串算品牌名」必然误报,而误报会挡住合法构建。 + +## 六、怎么验证 + +**新增单测一:hudi 分区值渲染(这是本任务唯一有行为风险的地方)** +`fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiScanRangePartitionValuesTest.java`,用 `new HudiScanRange.Builder().partitionValues(...)` 构造后调 `populateRangeParams(new TTableFormatFileDesc(), new TFileRangeDesc())`(现有 `HudiScanRangeTest` 就是这个套路),断言四种输入的 `columns_from_path` / `columns_from_path_is_null`: + +| 输入分区值 | 断言的值 | 断言的空值位 | +|---|---|---| +| `"2024-01-01"`(普通值) | `"2024-01-01"` | `false` | +| `"__HIVE_DEFAULT_PARTITION__"` | `"\N"` | `true` | +| Java `null` | `"\N"` | `true` | +| 字面量 `"\N"` | `"\N"` | `true` | + +再加一条:分区值为空 map 时,三个 `columns_from_path*` 字段一个都不设置(保持现状:现有代码在 `partValues` 空时整段跳过)。测试注释必须写清 WHY——**这四行断言就是「下沉不改字节」的证据**,hudi 的分区值来自路径目录名,所以这三条空值判定成立;hive / paimon 用的是别的判定,所以这段逻辑不能回到公共模块。 + +**变异验证(必须做,写进测试注释)**:把 `pathValues.add(nullValue ? HUDI_NULL_PARTITION_VALUE : value)` 改成 `pathValues.add(value)` → 第 3 行(Java `null`)必须变红;把 `nullValue` 的第三个条件(字面量 `\N`)删掉 → 第 4 行必须变红。两条各自能被对应的错误写法打红,才算钉住了「与原 `normalize` 逐字等价」。 + +**新增单测二:常量值冻结** +`fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorPartitionValuesTest.java`,两条断言:`NULL_PARTITION_NAME` 逐字等于 `"__HIVE_DEFAULT_PARTITION__"`;过时别名与新常量是同一个值。注释写清 WHY:改这个字面量会让已持久化的视图 / 物化视图分区与 BE 列路径解析对不上。这是一条「不许有人顺手美化字面量」的护栏测试,不是行为快照。 + +**回归既有用例**:`fe-core` 的 `PluginDrivenMvccExternalTableTest`、`ListPartitionItemTest`、`BrokerUtilTest`(后者覆盖 `FilePartitionUtils.parseColumnsFromPath`),以及 `fe-connector-paimon` 的 `PaimonConnectorMetadataPartitionTest`、`fe-connector-hive` 的 `HiveScanRangePartitionValuesTest` / `HiveConnectorMetadataPartitionListTest`。这些用例在改名后必须**不改断言**地继续通过。 + +**编译门禁(最强单一信号)**:全反应堆含测试源编译,**不许**加跳过测试编译的参数: + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile -DskipTests +``` + +这一条同时验证了「过时别名不影响任何现有编译单元」和「删掉的方法确实无人引用」。 + +**跑测试**必须显式关掉 maven build cache,否则 surefire 会被静默跳过、`BUILD SUCCESS` 是空的: + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml \ + -pl fe-connector/fe-connector-hudi,fe-connector/fe-connector-api,fe-connector/fe-connector-hive,fe-connector/fe-connector-paimon \ + -Dmaven.build.cache.enabled=false \ + -Dtest=HudiScanRangePartitionValuesTest+ConnectorPartitionValuesTest+HiveScanRangePartitionValuesTest+PaimonConnectorMetadataPartitionTest test +``` + +要读输出里的 `Tests run:` 行确认用例真的跑了,不要只看 `BUILD SUCCESS`。 + +**端到端回归**:本任务不改任何发给 BE 的字节,理论上不需要。若要保险,跑 `test_hive_partition_values_tvf`(表函数与视图里的哨兵字符串)与 `test_paimon_mtmv`(物化视图空分区刷新)两个既有套件即可,需要 docker 环境,本地不跑,不阻塞合并。 + +## 七、风险与回退 + +风险低,改动分成三块,每块的失败模式都很好识别: + +1. **改名**:编译期强制,改漏就编不过;过时别名保证了本仓库之外按旧名编译的连接器仍能通过。 +2. **hudi 下沉**:唯一有运行时语义的部分,但新代码与原 `normalize` 逐字等价,由第六节的四行断言加两次变异验证钉住。真出问题的表现是 hudi 分区表某一列的空值行读成字符串 `\N` 或反之——回退只需把那段循环换回 `ConnectorPartitionValues.normalize`(方法从 git 历史取回即可)。 +3. **fe-core 去重**:把一份重复的字面量换成引用,无行为变化。 + +需要留意的一点:`@Deprecated` 别名会让编译器对仍然引用旧名的代码报 deprecation 警告。本仓库内所有引用都在本任务里一次改完,因此不会新增警告;如果 CI 打开了「警告即错误」,那就必须确保改全(`grep -rn "HIVE_DEFAULT_PARTITION"` 应只剩公共模块的别名声明、以及测试与注释里写死字面量的地方)。 + +回退:三块互不依赖,可以单独 revert。 + +## 八、相关背景 + +- 调研报告 `plan-doc/connector-public-interface-cleanup/audit-report.md`: + - 第 8.1 节的清单表格中 `ConnectorPartitionValues.HIVE_DEFAULT_PARTITION` 那一行——本任务对应的那条建议:常量不删不改值、只做中立化命名 + 保留旧名别名一轮,三个归一方法下沉到唯一的生产调用方; + - 附录 A 第 22 条(公共 API 唯一的数据源品牌字符串常量,判定成立)、第 35 / 36 条(命名中立性与跨模块重复定义)、第 65 条(两个 public static 助手只有类内调用方)、第 89 条(结构化空值标志与哨兵**不是**两套冗余机制的复核收窄); + - 第十六节「明确不建议动的部分」第 10 条——哨兵的字符串值不能改(物化视图与表函数已持久化这些名字,BE 列路径解析也依赖它);附录 C.2「三处需要改结论」第 2 条——不能下沉到 hive 连接器(引擎自己的路径解析在用它,paimon 也主动往它归一,下沉只会变成三份定义)。 +- 代码里已有的两段权威说明,动手前建议先读:`PluginDrivenMvccExternalTable.java:308-323`(空值标志与哨兵的分工,以及 fe-core 不做字符串比较的理由)、`PaimonConnectorMetadata.java:1245-1262`(paimon 为什么主动往这个哨兵归一)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/21-centralize-scan-node-property-keys.md b/plan-doc/connector-public-interface-cleanup/tasks/21-centralize-scan-node-property-keys.md new file mode 100644 index 00000000000000..d0cc47c9002289 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/21-centralize-scan-node-property-keys.md @@ -0,0 +1,228 @@ +# 21. 把扫描节点属性表的键契约集中到公共模块 + +> **优先级**:第五优先级(结构) | **风险**:中 | **前置依赖**:无(与「把通用扫描节点里的 ES 专属分支搬进连接器」那一项任务同改 `PluginDrivenScanNode`,建议先做本任务,那一项就能直接把它要新增的三个中立合成键声明进本任务建的常量类) +> **影响模块**:`fe-connector-api`、`fe-core`、`fe-connector-hive`、`fe-connector-hudi`、`fe-connector-iceberg`、`fe-connector-paimon`、`fe-connector-jdbc`、`fe-connector-es` +> **预计改动规模**:约 14~16 个文件;新增一个约 150 行的常量类,其余文件净变化在 ±80 行量级(大部分是把字面量换成常量引用) +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +连接器通过 `ConnectorScanPlanProvider.getScanNodeProperties` 返回一张 `Map` 把扫描节点级信息交给引擎,但**这张表的键是什么、谁读、值怎么写,在公共模块里一个字都没有**——键一半藏在引擎的 `private` 常量里、一半散成各连接器里的裸字面量,新连接器只能去读引擎源码抄字符串;本任务把这份键契约集中成公共模块里一个常量类,同时顺手修掉同一片区域里两个已核实的小问题(两个返回面缺一句「只覆写一个」的说明、包装对象用构造器重载隐式编码布尔位)以及 `getSerializedTable(Map)` 这条绕私有键的弯路。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 属性表的两端 + +连接器一侧的入口在 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java:417`: + +```java +default Map getScanNodeProperties( + ConnectorSession session, ConnectorTableHandle handle, + List columns, Optional filter) { + return Collections.emptyMap(); +} +``` + +它的 javadoc(:403-416)只举了一个 ES 的例子,**没有列出任何一个键名**。 + +引擎一侧的消费者是 `fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java`,键定义在 :127-130,全部是 `private`: + +```java +/** Scan node property keys (shared with connector plugins). */ +private static final String PROP_FILE_FORMAT_TYPE = "file_format_type"; +private static final String PROP_PATH_PARTITION_KEYS = "path_partition_keys"; +private static final String PROP_LOCATION_PREFIX = "location."; +private static final String PROP_HIVE_TEXT_PREFIX = "hive.text."; +``` + +注释自称「shared with connector plugins」,但修饰符是 `private`——连接器根本引用不到,只能抄字面量。 + +### 2.2 引擎实际会读的键,逐个核实 + +| 键 | 引擎读取处 | 引擎拿它做什么 | +|---|---|---| +| `file_format_type` | `PluginDrivenScanNode.java:578`(另 :561 把它和 `"es_http"` 比) | 经 `mapFileFormatType`(:1954-1975)映射成 `TFileFormatType`;识别的值只有 `parquet`/`orc`/`text`/`csv`/`json`/`avro`/`es_http`,其余一律落到 `FORMAT_JNI` | +| `path_partition_keys` | `:588` | 逗号切分后作为 `getPathPartitionKeys()`,决定哪些列不从文件里解码 | +| `location.` 前缀 | `:788-789` | 剥掉前缀后作为 `getLocationProperties()`,即 BE 访问存储用的配置 | +| `hive.text.` 前缀 | `:799-863` | 组装 `TFileAttributes`;实际读 12 个后缀:`serde_lib`、`skip_lines`、`column_separator`、`line_delimiter`、`mapkv_delimiter`、`collection_delimiter`、`escape`、`null_format`、`enclose`、`trim_double_quotes`、`is_json`、`openx_ignore_malformed` | +| `query` | `:447` | 打进 EXPLAIN 的 `QUERY:` 行。**这一个连 `private` 常量都没有,是裸字面量** | + +反方向(引擎写、连接器读)还有三个合成键,`PluginDrivenScanNode.java:139-146` 定义、`PaimonScanPlanProvider.java:202-208` **按字节复制了一份**,两边注释都写着「keys are byte-identical … so the inject/consume sides stay in lockstep」:`__native_read_splits`、`__total_read_splits`、`__explain_verbose`。这是靠注释维持的字符串对齐,编译器完全不参与。 + +### 2.3 连接器一侧的现状 + +- hive:`HiveScanPlanProvider.java:82-84` 自己又定义了一份 `PROP_FILE_FORMAT_TYPE` / `PROP_PATH_PARTITION_KEYS` / `PROP_LOCATION_PREFIX`(值与引擎逐字相同);`hive.text.` 前缀在 `HiveTextProperties.java:87`。 +- hudi:`HudiScanPlanProvider.java:316`、`:317`、`:322`、`:331`、`:341`、`:352` 全是裸字面量。 +- iceberg:`IcebergScanPlanProvider.java:1554`、`:1572`、`:1588`、`:1599` 全是裸字面量。 +- paimon:`PaimonScanPlanProvider.java:751`、`:752`、`:765`、`:827`、`:839` 全是裸字面量。 +- jdbc:`JdbcScanPlanProvider.java:185` 写 `props.put("query", querySql)`。 +- es:`EsScanPlanProvider.java:184` 写 `"file_format_type"`;它自己那批键(`query_dsl` 等)有常量,在 :66-74。 + +顺带核实到两个**只写不读**的键:`table_format_type`(`PaimonScanPlanProvider.java:752`、`HudiScanPlanProvider.java:317` 写入,全仓库没有任何 `get` 端)与 `_table_name`(`EsScanPlanProvider.java:187` 写入,无读端)。它们不属于本任务范围(见 5.3)。 + +### 2.4 两个返回面 + +同一份属性还有第二个返回面,`ConnectorScanPlanProvider.java:455`: + +```java +default ScanNodePropertiesResult getScanNodePropertiesResult(...) { + return new ScanNodePropertiesResult(getScanNodeProperties(session, handle, columns, filter)); +} +``` + +核实结论(比调研报告更精确):**引擎只调这个包装面**(`PluginDrivenScanNode.java:1928`),**6 个**连接器覆写了 `Map` 面(es :156、hive :377、hudi :306、iceberg :1535、jdbc :161、paimon :739),其中 es 同时覆写了包装面(:165),所以真正靠默认委派生效的是**另外 5 个**。这**不是**两套竞争机制——包装面的默认实现显式调用了 `Map` 面。但由此产生一个静默陷阱:一个连接器如果两个面都覆写而实现不一致,`Map` 面的返回值会被彻底丢弃且不报错。接口 javadoc 现在没有任何一句话提醒这件事。 + +包装对象本身 `ScanNodePropertiesResult.java:46` 与 `:60` 是两个只差一个参数的构造器,一参版把 `hasConjunctTracking` 置 `false`、两参版置 `true`——**「有没有下推追踪」这个布尔位是靠「调用方选了哪个构造器」隐式编码的**,读代码的人看 `new ScanNodePropertiesResult(props)` 完全看不出自己顺带声明了「不做谓词裁剪」。全仓库只有 3 个构造点:`ConnectorScanPlanProvider.java:460`、`PluginDrivenScanNode.java:1932`、`EsScanPlanProvider.java:220`。 + +### 2.5 `getSerializedTable(Map)` 这条弯路 + +`ConnectorScanPlanProvider.java:520`: + +```java +default String getSerializedTable(Map nodeProperties) { + return null; +} +``` + +唯一实现是 `PaimonScanPlanProvider.java:1763-1765`:`return properties.get("paimon.serialized_table")`——而这个键正是它自己在 `:770` 写进属性表的。引擎侧 `PluginDrivenScanNode.java:1803-1813` 覆写了 `FileQueryScanNode.getSerializedTable()`(基类定义在 `FileQueryScanNode.java:339`,基类在 `:472` 把结果塞进 `params.setSerializedTable`,对应 thrift `TFileScanRangeParams.serialized_table`,`gensrc/thrift/PlanNodes.thrift:540`)。 + +所以调研报告说「承接的是引擎侧一个既有通用钩子」是对的。真正的多余是:paimon **已经**覆写了扫描级参数填充 `populateScanLevelParams`(`PaimonScanPlanProvider.java:1355`),拿到的正是同一个 `TFileScanRangeParams` 对象(`PluginDrivenScanNode.java:1823` 传的就是节点的 `params` 字段),它完全可以直接 `params.setSerializedTable(...)`,不必绕「写进属性表 → 引擎回调一个通用名方法 → 从属性表里把自己写的键取回来」这一圈。时序上也没问题:`PluginDrivenScanNode.createScanRangeLocations`(:1816-1826)先调 `super`(基类在 :472 设值),再调 `populateScanLevelParams`,后者在后面执行。 + +## 三、为什么这是个问题 + +1. **公共接口的契约不在公共模块**。这是本轮整治反复出现的同一条毛病:接口签名中立(`Map`),真正的语义写在引擎的 `private` 常量和各连接器的字面量里。结果是「新增一个连接器」这件事的成本被抬高到必须先读引擎源码——而读源码这件事本身不产生任何编译期保障。 +2. **字面量对齐靠注释维持**。三个合成键在引擎和 paimon 里各存一份,靠「byte-identical」的注释约束;`hive.text.` 的 12 个后缀在引擎侧是 `PROP_HIVE_TEXT_PREFIX + "column_separator"` 这类拼接、在 hive 侧是 `PROP_PREFIX + "column_separator"` 的另一次拼接。任一侧改一个字母,编译期毫无反应,运行期表现为「该属性静默失效」:比如 `column_separator` 拼错,文本表读出来整行挤在第一列,而不是报错。 +3. **中立接口上挂着源专属命名**。引擎的通用节点里有一个叫 `hive.text.` 的前缀,而它服务的是 TEXT/CSV/JSON 三个格式族、hudi 和 iceberg 走同一条 `getFileAttributes`。这与本项目「通用层不出现源专属符号」的既定纪律直接冲突。 +4. **两个返回面缺一句文档,就是一个静默丢结果的坑**。同时覆写两者不报错、不告警,`Map` 面被无声丢弃。这不是当前的活跃缺陷(现役连接器里只有 es 两个都覆写,且两者指向同一个私有实现 `buildScanNodeProperties`,行为一致),但它是给下一个连接器作者留的陷阱。 +5. **`getSerializedTable(Map)` 是公共接口上一个语义未定义的方法**。名字通用(「返回序列化后的表」),签名不说明什么算「表」、什么格式、给谁用,唯一实现是把自己写的私有键原样取回。新连接器看到这个方法无法判断自己该不该实现。 + +用户能观察到什么?这几条**都不会**表现为当前的错误结果——现役连接器的字符串是对齐的。真实后果是可维护性与新增连接器成本,以及一类「改错一个字母,编译通过、查询静默返回错数据」的高危改动窗口。 + +## 四、用一个最小例子说明 + +假设我要新增一个连接器(叫它 `foo`),表是分区的 Parquet 文件存在 S3 上。我今天要做的事: + +| 我作为连接器作者想做的事 | 今天实际必须怎么做 | 应该怎么做 | +|---|---|---| +| 告诉引擎「用原生 Parquet 读取器,别走 JNI」 | 去翻 `PluginDrivenScanNode.java:1954` 的 `switch`,才知道键叫 `file_format_type`、值必须正好是小写 `"parquet"`,然后在自己代码里写死这两个字面量 | `props.put(ScanNodePropertyKeys.FILE_FORMAT_TYPE, "parquet")`,识别的取值在常量类的 javadoc 里列着 | +| 告诉引擎「`dt` 列是目录分区列,不要从文件里解码」 | 翻到 `:588`,抄 `path_partition_keys`,还得自己发现值是逗号分隔 | `props.put(ScanNodePropertyKeys.PATH_PARTITION_KEYS, String.join(",", keys))` | +| 把 S3 凭证交给 BE | 翻到 `:788`,抄前缀 `location.`;抄错成 `locations.` 则编译通过、查询时 BE 对私有桶报 403 | `props.put(ScanNodePropertyKeys.LOCATION_PREFIX + k, v)` | +| 表是文本格式,要设分隔符 | 翻到 `:799-863` 一行行数出 12 个后缀,还要接受自己的通用连接器代码里出现 `hive.text.` 这个名字 | `props.put(ScanNodePropertyKeys.TEXT_COLUMN_SEPARATOR, "")` | +| 想知道属性表里 `__` 开头的键是什么 | 无处可查(引擎侧 `private`,只有 paimon 复制了一份) | 常量类里明确标注:这三个是引擎注入给 EXPLAIN 用的,永不发往 BE | + +一句话:今天新增连接器需要「读引擎源码 + 抄 5 处字面量」,改完后是「引用 1 个常量类」。 + +## 五、解决方案 + +### 5.1 目标状态 + +**(1)公共模块新增一个键常量类**,路径 `fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ScanNodePropertyKeys.java`,签名草案: + +```java +/** 扫描节点属性表(ConnectorScanPlanProvider#getScanNodeProperties 的返回值)的键契约。 */ +public final class ScanNodePropertyKeys { + + // ---- 连接器 -> 引擎:引擎会读的键 ---- + /** 取值:parquet / orc / text / csv / json / avro / es_http;其余(含缺省)= JNI 读取器。 */ + public static final String FILE_FORMAT_TYPE = "file_format_type"; + /** 逗号分隔的目录分区列名,大小写按 Doris 列名原样。 */ + public static final String PATH_PARTITION_KEYS = "path_partition_keys"; + /** 前缀;剥掉前缀后的键值对整体作为 BE 访问存储的配置。 */ + public static final String LOCATION_PREFIX = "location."; + /** 连接器渲染的远端查询文本,仅用于 EXPLAIN 的 QUERY: 行。 */ + public static final String REMOTE_QUERY = "query"; + + /** 文本类格式(TEXT/CSV/JSON)属性前缀。前缀值沿用历史字面量,与 hive 无关。 */ + public static final String TEXT_PROPERTY_PREFIX = "hive.text."; + public static final String TEXT_SERDE_LIB = TEXT_PROPERTY_PREFIX + "serde_lib"; + public static final String TEXT_SKIP_LINES = TEXT_PROPERTY_PREFIX + "skip_lines"; + // …其余 10 个后缀同上,一个不多一个不少(见 2.2 表格) + + // ---- 引擎 -> 连接器:合成键,永不发往 BE,只供 appendExplainInfo 读 ---- + public static final String SYNTHETIC_NATIVE_READ_SPLITS = "__native_read_splits"; + public static final String SYNTHETIC_TOTAL_READ_SPLITS = "__total_read_splits"; + public static final String SYNTHETIC_EXPLAIN_VERBOSE = "__explain_verbose"; + + private ScanNodePropertyKeys() {} +} +``` + +原则三条:**只收录引擎读的键与引擎注入的合成键**;**所有字面量的值一个字节都不改**;连接器私有键(`paimon.*`、`transactional_hive`、es 那批)留在各自连接器里不动。`TEXT_PROPERTY_PREFIX` 是「符号名中立、字面量保持历史值」的处理——改值需要同时动引擎与 hive 两侧,属于纯改名收益、不在本任务范围(见 5.3)。 + +**(2)包装对象改成具名工厂**,`ScanNodePropertiesResult`:两个构造器降为 `private`,新增 + +```java +public static ScanNodePropertiesResult of(Map properties); // 不做谓词裁剪 +public static ScanNodePropertiesResult withPushdownTracking(Map properties, + Set notPushedConjunctIndices); // 做谓词裁剪 +``` + +3 个构造点全部改成对应工厂。 + +**(3)两个返回面补文档**:在 `getScanNodeProperties` 与 `getScanNodePropertiesResult` 的 javadoc 上各加一段,明确「引擎只调用包装面;包装面的默认实现委派 `Map` 面;**连接器只应覆写其中一个**——若覆写包装面,`Map` 面不再被引擎调用,两者不一致时 `Map` 面的结果会被静默丢弃」。 + +**(4)`getSerializedTable(Map)` 走直接设置的路径**:从 `ConnectorScanPlanProvider` 删掉这个方法;paimon 在 `populateScanLevelParams` 里直接 `params.setSerializedTable(properties.get(PROP_SERIALIZED_TABLE))`(`paimon.serialized_table` 顺便提成 paimon 自己的私有常量,因为它同一个类里出现两次);引擎侧删掉 `PluginDrivenScanNode.getSerializedTable()` 覆写(:1803-1813)。删完后 `FileQueryScanNode.getSerializedTable()`(:339)与 `:472` 的调用在本仓库再无任何覆写者,一并删除(`fe-core` 纯减,符合只出不进)。 + +### 5.2 改动清单 + +| 文件 | 要做什么 | +|---|---| +| `fe-connector-api/.../scan/ScanNodePropertyKeys.java` | **新增**。按 5.1(1)建类,javadoc 写清每个键的取值约定、读取方是引擎还是连接器 | +| `fe-connector-api/.../scan/ConnectorScanPlanProvider.java` | `getScanNodeProperties`(:403-423)javadoc 指向常量类并加「只覆写一个面」的说明;`getScanNodePropertiesResult`(:437-462)同样加说明,默认实现改用 `ScanNodePropertiesResult.of(...)`;**删除** `getSerializedTable(Map)`(:510-522) | +| `fe-connector-api/.../scan/ScanNodePropertiesResult.java` | 两个构造器改 `private`,新增 `of` / `withPushdownTracking` 两个具名工厂,javadoc 说明两者语义差别 | +| `fe-core/.../datasource/scan/PluginDrivenScanNode.java` | 删 :127-130 与 :139-146 共 7 个 `private` 常量,全部改引用常量类;:447 的裸 `"query"` 改成 `ScanNodePropertyKeys.REMOTE_QUERY`;:1932 改用 `of(...)`;**删除** `getSerializedTable()` 覆写(:1803-1813);把 :943-952 与 :1905-1908 注释里提到的「serialized-table 路径」改成「扫描级参数填充路径」(事实变了,注释必须跟着变) | +| `fe-core/.../datasource/scan/FileQueryScanNode.java` | 删除 `getSerializedTable()`(:339-341)与 :472 的调用 | +| `fe-connector-hive/.../HiveScanPlanProvider.java` | 删自有的三个重复常量(:82-84),改引用公共常量类;`PROP_TRANSACTIONAL_HIVE`(:89)**保留**(连接器私有信号,自己的 `populateScanLevelParams` 读) | +| `fe-connector-hive/.../HiveTextProperties.java` | `PROP_PREFIX`(:87)改为引用 `ScanNodePropertyKeys.TEXT_PROPERTY_PREFIX`,各处拼接改用对应的具名后缀常量。注意 `hive.text.json_serde_lib`(:177)引擎不读,是 hive 私有键,留在本类里 | +| `fe-connector-hudi/.../HudiScanPlanProvider.java` | :316、:322、:331、:341、:352 的字面量改常量引用(`table_format_type` :317 不动,见 5.3) | +| `fe-connector-iceberg/.../IcebergScanPlanProvider.java` | :1554、:1572、:1588、:1599 的字面量改常量引用 | +| `fe-connector-paimon/.../PaimonScanPlanProvider.java` | :751、:765、:827、:839 改常量引用;删 :202-208 那三个复制的合成键常量,改引用公共常量类(`appendExplainInfo` 里的读取点随之改);**删** `getSerializedTable`(:1763-1765);`populateScanLevelParams`(:1355)里增加 `params.setSerializedTable(...)`;`paimon.serialized_table` 提为私有常量 | +| `fe-connector-jdbc/.../JdbcScanPlanProvider.java` | :185 的 `"query"` 改 `ScanNodePropertyKeys.REMOTE_QUERY` | +| `fe-connector-es/.../EsScanPlanProvider.java` | :184 改常量引用;**删除** `Map` 面覆写(:155-162)——引擎只调包装面(:165 已覆写),这个覆写从引擎侧不可达 | +| `fe-connector-es/.../EsScanPlanProviderTest.java` | :166 与 :341 两处调用 `getScanNodeProperties` 改为 `getScanNodePropertiesResult(...).getProperties()` | +| 相关单测 | paimon 加断言(见第六节);其余测试若引用了被删的构造器/方法,同步改到工厂方法 | + +### 5.3 明确不要顺手做的事 + +- **不要改任何键的字面量值**,包括不要把 `hive.text.` 改成 `text.`。改值必须引擎与 hive 同步改,收益纯粹是命名,风险是「漏改一处 → 文本表静默读错」。本任务只把符号名中立化。 +- **不要删只写不读的键**(paimon/hudi 的 `table_format_type`、es 的 `_table_name`、hive 的 `hive.text.json_serde_lib`)。它们是独立的死键清理,判活需要单独核对 BE 与 JNI 侧,混进本任务会把「纯结构调整」变成「有行为风险的删除」。 +- **不要碰 `PluginDrivenScanNode:561` 与 :1829-1835 那两处 ES 专属分支**。那是「把通用扫描节点里的源专属分支搬进连接器」那一项任务的正题;本任务只把它读的键换成常量,分支本身原样保留。 +- **不要试图和 `fe-core` 已有的 `FileFormatConstants.PROP_PATH_PARTITION_KEYS`(`FileFormatConstants.java:49`)合并**。那是表函数(TVF)的属性命名空间,字面量相同纯属巧合,且合并会往 `fe-core` 增加连接器相关代码,撞「只出不进」。 +- **不要把 `Map` 面删掉只留包装面**。5 个连接器靠默认委派生效,删掉等于强迫每个连接器去处理谓词追踪参数,是无谓的扩大改动。本任务只补文档、不改机制。 +- **不要为「键必须来自常量类」加 shell/正则构建门禁**。判断一个 `props.put` 的第一个实参是不是常量引用需要理解 Java 语义,本仓库已有明确结论:这类门禁误报比漏报更毒。改动本身由编译期常量引用保障。 + +## 六、怎么验证 + +1. **零残留 grep(存在性检查,可直接跑)**——这是本任务能机器验证的部分: + - `grep -rn '"file_format_type"\|"path_partition_keys"\|"location\."\|"hive\.text\.\|"__native_read_splits"\|"__total_read_splits"\|"__explain_verbose"' --include=*.java fe/fe-core/src/main fe/fe-connector/*/src/main` 期望只在 `ScanNodePropertyKeys.java` 里命中。 + - `grep -rn "getSerializedTable" --include=*.java fe/ | grep -v /target/` 期望 0 命中(`be-java-extensions` 里 BE 侧读 `serialized_table` 的那两处不在 `fe/fe-core` 与 `fe/fe-connector` 下,属另一侧,不受影响)。 +2. **paimon 的序列化表必须仍然到位**(唯一有运行期行为的改动,必须有断言)。在 `PaimonScanPlanProviderTest` 加一条:先 `getScanNodeProperties(...)` 拿到属性表,再 `populateScanLevelParams(new TFileScanRangeParams(), props)`,断言 `params.isSetSerializedTable()` 且值与属性表里 `paimon.serialized_table` 相等。WHY 要写进断言:BE 的 paimon JNI 读取器在 `be/src/format_v2/jni/paimon_jni_reader.cpp:68-71` 对缺失的 `serialized_table` 直接抛错("missing serialized_table … possibly caused by FE/BE version mismatch"),所以这个字段丢了就是 paimon 全表查询失败。**变异验证**:把新加的 `params.setSerializedTable(...)` 那一行注释掉 → 该断言必须变红。 +3. **两个返回面的委派关系钉一条单测**(放 `fe-connector-api` 的测试源):写一个只覆写 `Map` 面的匿名 `ConnectorScanPlanProvider`,断言 `getScanNodePropertiesResult(...).getProperties()` 拿到的是那张表、且 `hasConjunctTracking()` 为 `false`;再写一个覆写包装面并用 `withPushdownTracking` 的,断言 `hasConjunctTracking()` 为 `true`。WHY:这两条正是接口新增那段文档的行为承诺,文档不能只是注释。 +4. **es 删掉 `Map` 面覆写后行为不变**:`EsScanPlanProviderTest` 改到包装面后原有断言应全部照旧通过(两个覆写本来指向同一个私有实现 `buildScanNodeProperties`,:173)。这条不需要新增断言,通过即证。 +5. **编译门禁(最强单一信号)**:全反应堆**含测试源**编译,禁用任何跳过测试编译的参数—— + `mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile` + 它同时覆盖「删掉接口方法后所有实现都清理干净」「构造器改 `private` 后调用点全部改到工厂」「`import` 无残留(checkstyle 扫测试源)」三件事。 +6. **跑受影响模块的单测,必须显式关掉 maven build cache**(否则 surefire 会被静默跳过、`BUILD SUCCESS` 是空的): + `mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -Dmaven.build.cache.enabled=false -pl fe-connector/fe-connector-api,fe-connector/fe-connector-paimon,fe-connector/fe-connector-es,fe-connector/fe-connector-hive,fe-connector/fe-connector-hudi,fe-connector/fe-connector-iceberg,fe-connector/fe-connector-jdbc test` + `fe-core` 侧至少跑 `PluginDrivenScanNode*` 那一批(尤其 `PluginDrivenScanNodeVerboseExplainTest`、`PluginDrivenScanNodeExplainStatsTest`,它们覆盖合成键与 EXPLAIN 行)。 +7. **端到端回归**:不需要新增用例,但**必须实跑 paimon 目录的查询回归**(`serialized_table` 是它读数据的必要条件,第 2 条单测只覆盖 FE 侧装配)。hive 文本/CSV/JSON 表的读回归也建议跑一遍,因为 `hive.text.*` 的 12 个后缀经过了一次符号替换。其余连接器只有字面量换常量,属性表内容按字节不变。 + +## 七、风险与回退 + +- **最大风险是「换常量时打错一个后缀」**,尤其 `hive.text.*` 那 12 个。表现不是报错而是该属性静默失效(例如分隔符回退默认值 → 文本表列错位)。防线是第六节第 1 条的 grep(旧字面量必须全部消失)+ 第 7 条的 hive 文本表回归。建议实现时用「先把常量类写全、再逐文件替换、每替换一个文件立刻 grep 该文件是否还有旧字面量」的节奏,不要跨文件批量正则替换。 +- **paimon 的序列化表是硬失败点**:BE 侧对缺失直接抛错,不会静默降级。这既是风险也是好事——一旦漏设,回归立刻红,不会带着错数据上线。 +- **不涉及 thrift 有线格式**:`serialized_table` 字段(`PlanNodes.thrift:540`)本身不动,只是改由谁来 set。 +- **不涉及 Gson 持久化**:属性表只在单次查询规划期内存活,不进元数据镜像。 +- **接口有删除方法(`getSerializedTable`)与构造器降级为 `private`**:插件与公共模块必须同批构建、同批部署;混用老插件包 + 新公共模块会在类加载期报 `NoSuchMethodError`。本仓库连接器与 `fe-core` 一起构建发布,正常流程不会混用;手工替换单个插件 zip 需重新打包全部连接器。 +- **回退**:本任务是「新增一个常量类 + 引用替换 + 一处调用路径改写」,无数据面残留,`git revert` 单个提交即可完整回到原状。建议拆成两个提交(一是常量类 + 引用替换;二是两个返回面文档 + 具名工厂 + `getSerializedTable` 改写),这样第二个提交若有问题可单独回退。 + +## 八、相关背景 + +- 调研报告 `../audit-report.md`: + - 第 10.1 节(b)小节「扫描节点属性表」——本任务的主问题来源:键契约不在公共模块,一半散在引擎私有常量里、一半散在各连接器字面量里,建议在公共模块建一个键常量类; + - 第 10.5 节「方法名与行为不符 / 重载堆叠」最后一条——两个返回面(`Map` 面与包装对象面)同时覆写会静默丢掉一个,且包装对象用「调了哪个构造器」隐式编码一个布尔位; + - 附录 A 第 23 条——`getSerializedTable(Map)`,注意其中的「复核收窄」已确认它承接的是引擎既有通用钩子,本文按修正后的事实叙述; + - 附录 A 第 93 / 94 条与第 139 / 141 条——两个返回面与键契约散落的原始判定。 + - 第 8.3 节「通用引擎代码里残留的数据源分支」——通用扫描节点里的 ES 专属分支,是另一项任务,与本任务在同一文件相邻位置,见 5.3。 +- 设计纪律:见同目录 `07-write-down-the-design-rules.md`(通用层不出现源专属符号、`fe-core` 只出不进)。 +- 同批的接口删除类任务 `11-delete-dead-surface-batch-one.md`、`12-delete-dead-surface-batch-two.md`、`13-delete-scan-range-type-enum.md` 也改 `fe-connector-api` 的 `scan` 包,若同期进行请注意同文件冲突(与本任务无逻辑依赖)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/22-return-procedure-result-schema-to-connector.md b/plan-doc/connector-public-interface-cleanup/tasks/22-return-procedure-result-schema-to-connector.md new file mode 100644 index 00000000000000..a93307523d2ec7 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/22-return-procedure-result-schema-to-connector.md @@ -0,0 +1,215 @@ +# 22. 把分布式过程的结果列定义交还连接器 + +> **优先级**:第五优先级(中立化) | **风险**:中 | **前置依赖**:无 +> **影响模块**:`fe-connector-api`、`fe-connector-iceberg`、`fe-core`(**净减少**:删掉硬编码的四列定义与 3 个随之失效的 import,只加一处 SPI 调用) +> **预计改动规模**:约 10 个文件;生产代码新增约 110 行、删除约 25 行,测试改动约 120 行 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +`ALTER TABLE ... EXECUTE <过程名>` 有两种执行方式:单次同步调用(`SINGLE_CALL`)的结果列由连接器自己返回,而分布式编排(`DISTRIBUTED`)的结果列却被硬编码在引擎里 —— `ConnectorRewriteDriver.buildResult` 直接写死了 iceberg `rewrite_data_files` 那一个过程的四个列名和四个列类型。本任务把这四列的定义搬回 iceberg 连接器,引擎改为只负责编排每组的 `INSERT-SELECT`、把每组统计原样汇总成一个中立的统计对象交给连接器去渲染成结果行。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 两种执行方式的分派 + +`ConnectorProcedureOps.getExecutionMode(String)`(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/procedure/ConnectorProcedureOps.java:64-66`)默认返回 `SINGLE_CALL`,连接器可以覆写成 `DISTRIBUTED`。引擎在 `ConnectorExecuteAction` 里按这个声明分派(`fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/execute/ConnectorExecuteAction.java:142`、`:158`): + +- `SINGLE_CALL`:调 `procedureOps.execute(...)`(`:176`),连接器返回一个 `ConnectorProcedureResult`(schema + 行),引擎只是 `wrapResult` 包成结果集(`:213-225`)。**列名列类型完全由连接器决定。** +- `DISTRIBUTED`:构造 `ConnectorRewriteDriver` 并调 `driver.run()`(`:162-166`),驱动器返回 `ConnectorProcedureResult`,同样交给 `wrapResult`。**但这个 `ConnectorProcedureResult` 是引擎自己拼的。** + +全仓只有 iceberg 覆写了执行方式:`IcebergProcedureOps.getExecutionMode`(`fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergProcedureOps.java:108-113`)在名字等于 `rewrite_data_files` 时返回 `DISTRIBUTED`,其余全是 `SINGLE_CALL`。 + +### 2.2 引擎里硬编码的四列 + +`ConnectorRewriteDriver.java:245-264`: + +```java +private ConnectorProcedureResult buildResult(int rewrittenDataFilesCount, int addedDataFilesCount, + long rewrittenBytesCount, int removedDeleteFilesCount) { + // Four-column schema in the exact legacy order/types (IcebergRewriteDataFilesAction.getResultSchema); + // rewritten_bytes_count is INT for byte-parity with legacy (a latent quirk kept on purpose). + List schema = ImmutableList.of( + new ConnectorColumn("rewritten_data_files_count", ConnectorType.of("INT"), ...), + new ConnectorColumn("added_data_files_count", ConnectorType.of("INT"), ...), + new ConnectorColumn("rewritten_bytes_count", ConnectorType.of("INT"), ...), + new ConnectorColumn("removed_delete_files_count", ConnectorType.of("BIGINT"), ...)); + ... +} +``` + +四个列名、四个列类型、四段列注释文本,全部是 iceberg 这一个过程的历史行为,全部住在 `fe-core` 的一个通用引擎类里。 + +### 2.3 四个统计数字从哪来 + +`ConnectorRewriteDriver.java:177-183`:三个数字由引擎对连接器给出的分组对象求和,一个由事务在提交后回报。 + +```java +int addedDataFilesCount = rewriteTx.getRewriteAddedDataFilesCount(); +int rewrittenDataFilesCount = groups.stream().mapToInt(ConnectorRewriteGroup::getDataFileCount).sum(); +long rewrittenBytesCount = groups.stream().mapToLong(ConnectorRewriteGroup::getTotalSizeBytes).sum(); +int removedDeleteFilesCount = groups.stream().mapToInt(ConnectorRewriteGroup::getDeleteFileCount).sum(); +``` + +其中 `getRewriteAddedDataFilesCount()` 来自窄能力接口 `RewriteCapableTransaction`(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/handle/RewriteCapableTransaction.java:43-51`),只有提交后才有效。 + +还有一条**空计划早退路径**:`ConnectorRewriteDriver.java:122-125`,当连接器规划出零个分组时,引擎**不开事务**直接返回 `buildResult(0, 0, 0, 0)`。这条路径同样需要列定义,而它发生在任何远程调用之前。 + +### 2.4 连接器一侧本来就有现成的落点 + +`BaseIcebergAction` 已经有 `protected List getResultSchema()`(`fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/action/BaseIcebergAction.java:140-142`,默认空表),构造时捕获一次(`:81`),八个单次调用过程都是通过覆写它来声明自己的结果列的。唯独 `IcebergRewriteDataFilesAction`(`.../action/IcebergRewriteDataFilesAction.java:47`)没有覆写它 —— 因为它的 `executeAction` 是个永不可达的守卫抛异常(`:168-176`),结果列被搬到引擎去了。上面那段引擎注释里的 `IcebergRewriteDataFilesAction.getResultSchema` 指的是**迁移前 fe-core 里的旧同名类**,现在的连接器类里并没有这个方法。 + +### 2.5 中立模块文档里的源专有列名 + +`ConnectorRewriteGroup`(`.../api/procedure/ConnectorRewriteGroup.java`)是中立分组对象,但它的文档用 iceberg 的结果列名来解释每个字段的用途:`:47`(`rewritten_data_files_count`)、`:49`(`rewritten_bytes_count`)、`:51`(`removed_delete_files_count`),三个 getter 注释 `:67`/`:72`/`:77` 又各重复一遍。`RewriteCapableTransaction.java:48` 也提到 `added_data_files_count`。 + +## 三、为什么这是个问题 + +1. **不对称**:同一个 SPI 的两种执行方式,一种把结果列的所有权交给连接器,另一种留在引擎。读代码的人无法从 `ConnectorProcedureOps` 的接口面看出「分布式过程的结果列谁定」。 +2. **新连接器必须改公共模块**:任何连接器想加第二个分布式过程(例如 paimon 的 compact),它的结果列只能通过改 `fe-core` 的 `ConnectorRewriteDriver` 来表达。而 `fe-core` 当前阶段是「只出不进」,往里加第二个数据源的列定义方向就是错的。更糟的是:`buildResult` 只有一套列,两个连接器的两个分布式过程会在这里正面冲突,只能在通用引擎类里按过程名分支。 +3. **一处历史遗留的类型 quirk 归属错了**:`rewritten_bytes_count` 声明成 `INT`,而它承载的值是 `long` 求和出来的字节数(`:179`);对称地,`removed_delete_files_count` 声明成 `BIGINT`,而它承载的值是 `int`。这两处「类型与直觉不符」是那个 iceberg 过程的历史行为(为与迁移前逐字一致而**刻意保留**),不是引擎的行为规范。放在引擎里,它看起来像是「分布式过程的通用结果契约」,会被下一个连接器照抄。 +4. 用户目前观察不到任何错误:iceberg 是唯一的分布式过程连接器,行为完全正确。**这是一个归属问题,不是正确性缺陷。** 但它是「新增连接器必须动公共模块」清单上的一项。 + +## 四、用一个最小例子说明 + +用户今天执行: + +```sql +ALTER TABLE ice_ctl.db1.t EXECUTE rewrite_data_files("min-input-files" = "2"); +``` + +| 用户写了什么 | 现在实际发生什么 | 应该发生什么 | +|---|---|---| +| 上面这条 SQL | iceberg 连接器规划分组 → 引擎跑 N 个 `INSERT-SELECT` → 引擎**自己**造出 `rewritten_data_files_count / added_data_files_count / rewritten_bytes_count / removed_delete_files_count` 四列并填值 | iceberg 连接器规划分组 → 引擎跑 N 个 `INSERT-SELECT` → 引擎把四个统计数字打成中立统计对象交回连接器 → **连接器**造出同样的四列同样的值 | +| 结果集 | `+---+---+------+---+`(四列,值不变) | **逐字相同**(列名、列顺序、列类型、值全不变) | + +再看「新增一个分布式过程」的成本。假设我要给 paimon 加一个分布式的 `compact` 过程,返回两列 `compacted_file_count` / `compacted_bytes`: + +| 今天必须动的文件 | 改完之后必须动的文件 | +|---|---| +| `fe-connector-paimon`:覆写 `getExecutionMode` + 实现 `planRewrite` | 同左 | +| **`fe-core/ConnectorRewriteDriver.java`**:给 `buildResult` 加一个「如果是 paimon 的 compact 就换另一套列」的分支 | **不用动** | + +## 五、解决方案 + +### 5.1 目标状态 + +引擎只做三件事:编排、把每组统计原样求和、把结果原样透传。列定义与行渲染全在连接器。 + +**新增一个中立的统计对象**(`fe-connector-api`,`org.apache.doris.connector.api.procedure` 包): + +```java +public final class ConnectorRewriteStatistics { + // 前三项是引擎对连接器自己给出的每组 ConnectorRewriteGroup 数字做的直和(不做任何换算、不改单位); + // 最后一项来自 RewriteCapableTransaction#getRewriteAddedDataFilesCount(),仅提交后有效。 + public ConnectorRewriteStatistics(int dataFileCount, long totalSizeBytes, + int deleteFileCount, int addedDataFileCount); + public int getDataFileCount(); + public long getTotalSizeBytes(); + public int getDeleteFileCount(); + public int getAddedDataFileCount(); +} +``` + +字段名与 `ConnectorRewriteGroup` 的三个 getter 同名,让「谁汇总成谁」一眼可见;四个字段都是中立词,不含任何数据源名与列名。 + +**`ConnectorProcedureOps` 新增一个默认抛异常的方法**(与 `planRewrite`(`:87-97`)的既有写法完全对称): + +```java +/** + * 把引擎编排出的统计渲染成该分布式过程的结果(列 schema + 行)。只对 getExecutionMode 返回 + * DISTRIBUTED 的过程有意义;默认失败到底,避免误路由静默返回空结果集。 + * + * 实现约束:必须是纯本地渲染 —— 不得加载表、不得发起远程调用、不得要求鉴权作用域。 + * 引擎在「零分组早退」路径上也会调它(此时还没有开事务)。 + */ +default ConnectorProcedureResult buildRewriteResult(String procedureName, + ConnectorRewriteStatistics statistics) { + throw new UnsupportedOperationException( + "buildRewriteResult is only implemented for DISTRIBUTED procedures; '" + + procedureName + "' is not one"); +} +``` + +**iceberg 一侧**:把四列定义放进 `IcebergRewriteDataFilesAction`(它就是这个过程的连接器落点),并让它同时覆写 `getResultSchema()` 返回同一个常量,与八个单次调用兄弟保持一致(今天不可达,但让这个动作自描述)。`IcebergProcedureOps.buildRewriteResult` 只做过程名校验 + 委派,不进 `runInAuthScope` / `planInAuthScope`。 + +### 5.2 改动清单 + +| 文件 | 做什么 | +|---|---| +| `fe-connector-api/.../api/procedure/ConnectorRewriteStatistics.java` | **新增**。四个 final 字段 + 四个 getter + `toString`。不做 null 检查(全是基本类型) | +| `fe-connector-api/.../api/procedure/ConnectorProcedureOps.java` | 在 `planRewrite`(`:87-97`)之后新增默认方法 `buildRewriteResult`,默认抛 `UnsupportedOperationException`;文档写清「纯本地渲染、不得远程调用」这条约束 | +| `fe-connector-api/.../api/procedure/ConnectorRewriteGroup.java` | **仅文档**。把 `:47` / `:49` / `:51` 与 `:67` / `:72` / `:77` 里的 iceberg 列名改述为中立说法:这是「按文件路径原子替换的合并模型」,字段分别是本组被替换的数据文件数、字节总量、附带的删除文件数;`:31` 那句列举 iceberg 判据的话改成「由连接器自行定义选文件与分组判据」 | +| `fe-connector-api/.../api/handle/RewriteCapableTransaction.java` | **仅文档**。`:48` 的 `added_data_files_count` 改述为「新增数据文件数这项统计,是引擎无法从规划分组算出、只能由连接器在提交后回报的那一项」 | +| `fe-core/.../execute/ConnectorRewriteDriver.java` | 删掉 `buildResult`(`:245-264`)整个方法;`:122-125` 的早退改为 `procedureOps.buildRewriteResult(procedureName, new ConnectorRewriteStatistics(0, 0L, 0, 0))`;`:182-183` 改为把 `:177-180` 求出的四个数字装进 `ConnectorRewriteStatistics` 后调 `buildRewriteResult` 并原样返回;删掉随之失效的 3 个 import(`ConnectorColumn`:22、`ConnectorType`:25、`ImmutableList`:41 —— 已核实这三个只在 `buildResult` 里用到);类注释 `:64` 那句 “emit the four-column result row” 改成「让连接器渲染结果行」 | +| `fe-connector-iceberg/.../action/IcebergRewriteDataFilesAction.java` | 新增 `private static final List RESULT_SCHEMA`,**四个列名、四个列类型、四段列注释文本从引擎逐字搬过来**(含 `rewritten_bytes_count` = `INT`、`removed_delete_files_count` = `BIGINT` 两处刻意保留的历史 quirk,注释里写明这是历史行为、故意不改);覆写 `getResultSchema()` 返回它;新增 `public static ConnectorProcedureResult buildResult(ConnectorRewriteStatistics stats)`,按「数据文件数、新增数据文件数、字节总量、删除文件数」的**原顺序**填行 | +| `fe-connector-iceberg/.../IcebergProcedureOps.java` | 覆写 `buildRewriteResult`:过程名不是 `rewrite_data_files` 时抛 `DorisConnectorException`(照 `planRewrite`:139-142 的写法),否则返回 `IcebergRewriteDataFilesAction.buildResult(statistics)` | +| `fe-connector-api/src/test/.../ConnectorProcedureOpsDefaultsTest.java` | 照 `planRewriteDefaultsToUnsupported`(`:158-168`)加一条 `buildRewriteResultDefaultsToUnsupported`;再加一条断言统计对象四个 getter 各自取到正确字段 | +| `fe-connector-iceberg/src/test/.../IcebergProcedureOpsTest.java` | **本任务的主断言落点**,见第六节 | +| `fe-core/src/test/.../ConnectorRewriteDriverTest.java` | `:84-93` 的四列名/四类型/全零行断言**移走**(改由 iceberg 单测承担);改为断言引擎的编排职责:用 `ArgumentCaptor` 抓 `buildRewriteResult` 收到的 `ConnectorRewriteStatistics`,并断言驱动器把连接器返回的 `ConnectorProcedureResult` **原样**返回。注意:mock 的 `buildRewriteResult` 默认返回 `null`,不 stub 会让 `run()` 返回 null | +| `fe-core/src/test/.../ConnectorExecuteActionTest.java` | `:230-258` 与 `:397-415` 两条分布式用例必须补 stub —— 不 stub 会在 `ConnectorExecuteAction.wrapResult`(`:214`)对 null 解引用而 NPE;`:257` 那条「全零四列行」断言改成断言引擎透传了 stub 返回的结果 | + +### 5.3 明确不要顺手做的事 + +- **不要修 `rewritten_bytes_count` 的 `INT` 类型,也不要修 `removed_delete_files_count` 的 `BIGINT`。** 这是本任务的红线:搬家必须逐字,列名、列顺序、列类型、列注释文本一个字都不能变。修 quirk 是另一件事,要单独提、单独评审、单独跑端到端(改类型会改变客户端看到的列元数据)。 +- **不要把 `ConnectorRewriteGroup` 也一起搬进连接器。** 它是引擎编排真正要读的对象(按 `getDataFilePaths()` 给每组扫描定范围,见 `ConnectorRewriteDriver.java:192-198`),必须留在中立模块。本任务只改它的文档措辞。 +- **不要重命名 `ConnectorRewriteDriver` / `planRewrite` / `RewriteCapableTransaction`。** 「rewrite」在这里是合并重写这个操作模型的中立词,不是数据源名。 +- **不要把求和逻辑挪进连接器。** 引擎跑了 N 组、引擎知道跑了几组,汇总是编排的一部分;连接器给的每组数字被原样直和,不做换算、不改单位。 +- **不要顺手给 `getExecutionMode` 加第三种模式,也不要顺手给 paimon/hudi 加分布式过程。** 第四节里的 paimon compact 只是用来说明成本的假想例子。 +- **不要为「结果列不得出现在公共模块」写 shell / 正则门禁。** 判断一个字符串字面量是不是结果列名需要理解 Java 语义,本仓库已有结论:这类门禁只适合存在性与前缀类不变量。靠单测 + 评审。 +- **不要动 `ProcedureExecutionMode` 枚举本身。** 它的两个值和文档都是中立的,只是文档里拿 iceberg 举例,属另一批文档措辞任务。 + +## 六、怎么验证 + +### 6.1 连接器单测(本任务的主断言) + +在 `fe-connector-iceberg` 的 `IcebergProcedureOpsTest` 里新增: + +1. `buildRewriteResultDeclaresFourLegacyColumns`:调 `procOps.buildRewriteResult("rewrite_data_files", new ConnectorRewriteStatistics(3, 4096L, 1, 2))`,断言 + - 列名有序等于 `["rewritten_data_files_count", "added_data_files_count", "rewritten_bytes_count", "removed_delete_files_count"]`; + - 列类型有序等于 `["INT", "INT", "INT", "BIGINT"]`(取 `getType().getTypeName()`,与被删掉的 `ConnectorRewriteDriverTest.java:90-91` 同一断言形状);测试注释里必须写明第三列的 `INT` 与第四列的 `BIGINT` 是**刻意保留的历史行为**,看到就改是错的; + - 单行等于 `["3", "2", "4096", "1"]`。**四个数字必须互不相同**:这样把「新增数」和「重写数」填反、或把字节数与删除数填反,都会让断言变红(变异验证的着眼点就在这里 —— 用全零或用重复值的断言杀不掉填错顺序)。 +2. `buildRewriteResultRejectsNonDistributedProcedure`:传 `"rollback_to_snapshot"` 应抛 `DorisConnectorException`(照 `planRewriteRejectsNonRewriteProcedure`(`IcebergProcedureOpsTest.java:208-215`)写)。 +3. `buildRewriteResultDoesNotTouchTheCatalog`:用现有 fixture 断言这次调用没有触发任何 `loadTable`(对应 5.1 里「纯本地渲染」那条契约;这条契约支撑引擎的零分组早退路径 —— 那时还没有事务、也没有鉴权作用域)。 + +在 `fe-connector-api` 的 `ConnectorProcedureOpsDefaultsTest` 里新增默认实现抛 `UnsupportedOperationException` 的断言(变异点:默认改成返回空结果 → 误路由变成静默空结果集 → 该断言变红)。 + +### 6.2 引擎单测(职责改成编排) + +改写后的 `ConnectorRewriteDriverTest`: + +- 空计划早退:仍断言 `metadata.beginTransaction` 一次都没被调(保留 `:94-98` 那条变异守卫),并断言驱动器调了 `buildRewriteResult` 且传进去的统计四项全为 0; +- 透传:stub `buildRewriteResult` 返回一个自造的单列结果,断言 `run()` 返回的就是**同一个对象**(`assertSame`),即引擎不再对结果做任何加工。 + +### 6.3 端到端回归 + +**先纠正最初那轮调研里的一条说法**:现有端到端用例并**不**断言列名。已核实 `regression-test/suites/external_table_p0/iceberg/action/test_iceberg_rewrite_data_files.groovy:160-170` 只断言结果非空并按**下标** `[0][0]` / `[0][1]` / `[0][2]` 取值;`test_iceberg_rewrite_data_files_where_conditions.groovy:87-90`、`:117-119`、`:139-145` 同样按下标断言四个值的正负与零。所以: + +- **列名与列类型的逐字一致只能靠 6.1 的连接器单测保证**,端到端兜不住; +- 端到端兜的是**列顺序与取值**:把上述两个用例(外加 `test_iceberg_rewrite_data_files_parallelism.groovy`、`test_iceberg_v3_row_lineage_rewrite_data_files.groovy`)在本地集群跑一遍,下标语义不变即通过。这四个用例已覆盖三条关键路径:正常重写、`WHERE` 收窄重写、`WHERE` 不命中(零分组早退路径,`[0][0..3]` 全 0)。 +- 可选加强(不强制):在 `test_iceberg_rewrite_data_files.groovy` 里补一句把结果集列名打进日志的断言,让列名从此有端到端护栏。若加,必须与连接器单测的名字一致,不要新造措辞。 + +### 6.4 编译门禁 + +含测试源的全反应堆编译(禁用跳过测试编译的参数): + +``` +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml test-compile +``` + +这是最强的单一验收信号 —— 新增的 SPI 默认方法不会破坏任何连接器,但 `fe-core` 两个测试类若忘了补 stub 会在这里就暴露成编译或运行失败。跑具体单测时必须禁用 maven build cache,否则 surefire 会被静默跳过而仍然 `BUILD SUCCESS`。 + +## 七、风险与回退 + +- **主要风险:搬家时把列写错。** 列名拼错、顺序颠倒、类型写反,用户可见的结果集就变了。控制手段是 6.1 里那条「四个互不相同的数字」断言 + 端到端下标断言。 +- **次要风险:漏了空计划早退路径。** 如果只改了 `:182-183` 而漏了 `:122-125`,零分组时会走进老的 `buildResult`(已删)导致编译失败 —— 这个漏项由编译器兜住,不会静默。 +- **第三个风险:把 `buildRewriteResult` 实现成需要加载表。** 那会让「没有分组时不开事务、不做远程调用」这条既有性质退化,鉴权作用域也无处安放。由 6.1 第 3 条断言兜住。 +- **回退**:本任务不涉及持久化格式、不涉及 thrift 有线格式、不涉及 Gson 类型标签,结果集只在 FE 内部经 `CommonResultSet` 直接返回客户端。单个提交 revert 即可完全回到现状。 +- 类加载器方面无新风险:新增的统计对象是纯数据的中立类,编在 `fe-connector-api`(公共模块,编进 `fe-core`),与 `ConnectorRewriteGroup` 走同一条路径。 + +## 八、相关背景 + +- `audit-report.md` 第八主题 8.2 节(「名字中立,但语义只对一个数据源成立」)里关于 `ProcedureExecutionMode.DISTRIBUTED` 的那一条,是本任务的出处。 +- `audit-report.md` 附录 A 第 27 条给出了收窄后的准确表述:问题是**结果 schema 的所有权**错放在 `fe-core`,与 `SINGLE_CALL` 由连接器返回 `ConnectorProcedureResult` 不对称。 +- `audit-report.md` 第十五节整治路线表的第 9 批「中立化」把本任务与 19、20、21 号排在一起;同批任务都改 `fe-connector-api`,但本任务只碰 `procedure` 与 `handle` 两个包,与它们无文件重叠。 +- 对称参照:`RewriteCapableTransaction` 是「窄能力接口 opt-in,而不是往共享契约上加源专有方法」的既有先例(`RewriteCapableTransaction.java:22-30` 的类注释写明了这个理由);本任务新增的默认抛异常方法沿用 `planRewrite` 的同一范式。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/23-split-connector-context-storage-services.md b/plan-doc/connector-public-interface-cleanup/tasks/23-split-connector-context-storage-services.md new file mode 100644 index 00000000000000..5efa071dd2c7a2 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/23-split-connector-context-storage-services.md @@ -0,0 +1,292 @@ +# 23. 把引擎上下文里的存储服务收成独立服务对象(高危) + +> **优先级**:第五优先级(高危,排在整条工作线最后) | **风险**:高 | **前置依赖**:任务 06(必须先合入) +> **影响模块**:`fe-connector-spi`(接口拆分 + 单测)、`fe-connector-hive`、`fe-connector-iceberg`、`fe-connector-paimon`、`fe-connector-hudi`、`fe-connector-jdbc`(只涉及改名那一小步)、`fe-core`(只加两处接线,不搬逻辑) +> **预计改动规模**:约 22~25 个文件;新增 1 个接口文件(约 270 行,javadoc 原样搬过去)、`ConnectorContext` 减约 265 行、连接器侧 35 处生产调用点 + 9 个测试替身的机械替换。净增长接近于零。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +`ConnectorContext` 是引擎交给连接器的唯一服务入口,今天它把 19 个方法压在一个接口上,其中 11 个全是「存储与 BE 侧」的事(凭证、地址归一、文件系统、broker、BE 探测……)。本任务把这 11 个方法收进一个独立的服务接口 `ConnectorStorageContext`,`ConnectorContext` 只留一个取得器;顺带把长在这个中立接口上的 `sanitizeJdbcUrl` 改成中立命名并把它的安全契约写准。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 一个接口装了两类完全不相干的东西 + +`fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorContext.java:36` 共 19 个方法(文件 415 行)。按职责分成两堆,界线非常干净: + +**引擎装配与运行时服务(8 个,留在 `ConnectorContext`)** + +| 方法 | 位置 | 做什么 | +|---|---|---| +| `getCatalogName()` / `getCatalogId()` | `:39` / `:42` | 目录身份,唯一两个抽象方法 | +| `getEnvironment()` | `:54` | FE 进程级配置项表 | +| `getHttpSecurityHook()` | `:63` | 对外 HTTP 前后的安全钩子 | +| `sanitizeJdbcUrl(String)` | `:79` | 出站地址消毒(见 2.5) | +| `executeAuthenticated(Callable)` | `:98` | 把操作包进目录的认证上下文 | +| `getMetaInvalidator()` | `:109` | 元数据失效通知(任务 14 要删) | +| `createSiblingConnector(String, Map)` | `:147` | 异构网关的兄弟连接器工厂 | + +**存储与 BE 侧服务(11 个,本任务要搬走)** + +| 方法 | 位置 | 做什么 | +|---|---|---| +| `vendStorageCredentials(Map)` | `:165` | 把 REST 目录发的临时凭证归一成 BE 认的键 | +| `normalizeStorageUri(String)` | `:188` | 把连接器的原生路径归一成 BE 认的规范 scheme | +| `normalizeStorageUri(String, Map)` | `:208` | 上一条的「带临时凭证」重载 | +| `newStorageUriNormalizer(Map)` | `:230` | 上一条的批量形式(每次扫描只推导一次存储配置) | +| `getBackendFileType(String, Map)` | `:255` | 告诉 BE 用哪一族文件系统打开输出路径 | +| `getBrokerAddresses()` | `:291` | broker 写入时的 broker 地址 | +| `getBackendStorageProperties()` | `:314` | 目录静态凭证归一成 BE 认的键 | +| `testBackendStorageConnectivity(int, Map)` | `:337` | 建目录时让 BE 探一次存储可达性 | +| `getStorageProperties()` | `:362` | 目录的类型化存储配置(`fe-filesystem` 的契约对象) | +| `getFileSystem(ConnectorSession)` | `:390` | 引擎持有的按 scheme 路由的文件系统 | +| `cleanupEmptyManagedLocation(String, List)` | `:412` | 删表后清理空目录壳 | + +存储那 11 个方法的 javadoc(`:151`~`:415`,约 265 行)占了整个文件的三分之二。 + +### 2.2 谁在用这 11 个方法 + +全仓库检索连接器生产代码(`fe/fe-connector/*/src/main`),共 **35 处**调用点,集中在 4 个连接器: + +| 连接器 | 调用点数 | 具体位置 | +|---|---|---| +| hive | 14 | `HiveScanPlanProvider.java:153/157/258/405/619`、`HiveWritePlanProvider.java:153/155/269/327/328/339`、`HiveConnectorTransaction.java:756`、`HiveConnectorMetadata.java:910/942` | +| iceberg | 14 | `IcebergWritePlanProvider.java:614/622/635/674/679`、`IcebergScanPlanProvider.java:1464/1585/1598`、`IcebergConnector.java:443/450/899/1205`、`IcebergConnectorMetadata.java:938/1087` | +| paimon | 4 | `PaimonScanPlanProvider.java:735/823/837`、`PaimonConnector.java:437` | +| hudi | 3 | `HudiScanPlanProvider.java:331/424/939` | + +其余连接器(es、jdbc、maxcompute、trino)**一处都不用**——它们没有存储概念,却同样看着这 11 个方法。 + +引擎侧的实现只有一个:`fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java:78`(598 行)。这 11 个方法的实现体占 `:200`~`:507`,加上私有辅助方法一直到 `:566`,以及 5 个字段(`:91` 存储配置供给器、`:96` 原始存储属性供给器、`:103` 文件系统锁、`:104` 缓存的文件系统、`:105` 关闭标记)。文件系统还与生命周期绑定:`close()`(`:371`)关掉缓存的文件系统,由 `PluginDrivenExternalCatalog` 的私有方法 `closeConnectorContextQuietly`(声明在 `:1374`)关掉,调用点在 `:1401`(目录销毁)与 `:158`(重建上下文时关掉旧的)。 + +### 2.3 为什么这次改动被定为高危:两个逐方法转发的钉桩包装类 + +iceberg 与 paimon 各有一个装饰器,把线程上下文类加载器钉到插件加载器上(这是本项目已经踩过四次的类加载器分裂事故的防护): + +- `fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/TcclPinningConnectorContext.java:74`——`executeAuthenticated` 带钉桩逻辑在 `:98-114`,其后 `:116-210` 全是「纯转发」,其中存储那一段是 `:156-209`; +- `fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/TcclPinningConnectorContext.java:63`——钉桩在 `:76-92`,存储转发在 `:134-178`。 + +已核实的转发缺口(也是任务 06 的内容):iceberg 覆写 18/19,漏 `getFileSystem`;paimon 覆写 17/19,漏 `getFileSystem` 与 `newStorageUriNormalizer`。这两个类正文里明确写了哪些方法**不需要**钉桩(地址归一、BE 连通性探测完全跑在引擎侧,见 iceberg `:173-177` 与 `:203`)。 + +**风险就在这里**:这个接口一动,两个类都要跟着动;而它们承载的是「远程提交时 iceberg-aws 按类名反射加载 S3 客户端」这类只有真跑起来才暴露的机制,单测覆盖不到全部。所以必须做插件包重部署冒烟。 + +### 2.4 测试替身的规模 + +`ConnectorContext` 全仓有 25 个实现:8 个具名 + 17 个匿名。其中生产实现只有 3 个(`DefaultConnectorContext` 与 iceberg / paimon 两个钉桩包装类),另 **22 个是测试替身**。真正覆写了存储方法的替身共 9 处: + +`fe-connector-iceberg/src/test/.../RecordingConnectorContext.java:45`(8 个存储覆写:98 / 104 / 109 / 116 / 127 / 137 / 142 / 183 行)、`fe-connector-hive/src/test/.../RecordingConnectorContext.java:37`(6 个)、`fe-connector-paimon/src/test/.../RecordingConnectorContext.java:39`(3 个),以及 `IcebergConnectorTestConnectionTest`、`HiveConnectorTransactionTest`、`HiveScanBatchModeTest`、`PaimonScanPlanProviderTest`、`HudiBackendDescriptorTest`(2 处匿名)里的内联替身。它们全部标了 `@Override`(已核实)。 + +`fe-core` 的 7 个 `DefaultConnectorContext*Test`(BackendStorageProps / Cleanup / FileSystem / NormalizeUri / Sibling / StorageProps / Vend)全部用**具体类型**声明变量(已逐个核实),所以只要引擎实现类同时实现新接口,这 7 个测试**一行都不用改**。 + +### 2.5 顺带要处理的 `sanitizeJdbcUrl` + +`ConnectorContext:79` 上挂着 `sanitizeJdbcUrl(String)`,javadoc 写的契约是: + +``` +Connectors MUST call this method before using any JDBC URL to establish a database connection. +``` + +已核实的事实: + +- 引擎实现走 `SecurityChecker.getInstance().getSafeJdbcUrl(...)`(`DefaultConnectorContext.java:186-192`),失败抛异常; +- 全仓**唯一**的调用者是 `fe-connector-jdbc`:`JdbcDorisConnector.java:241` 把 `context::sanitizeJdbcUrl` 作为**方法引用**传进 `JdbcConnectorClient.create(...)`,客户端在 `JdbcConnectorClient.java:182` 建连前应用它; +- iceberg 的 JDBC 元存储(`IcebergCatalogFactory.java:601-602`)与 paimon 的 JDBC 目录(`PaimonCatalogFactory.java:196` 把 `uri` 直接塞进 paimon 的 `JdbcCatalogFactory`)都把用户地址原样交给第三方 SDK 建连,从不经过这个钩子。 + +**这不是迁移引入的回退**:上游 master 的老代码同样没有在这两条路上做检查。所以这里要修的是「契约写得比实现宽」,不是补一个安全漏洞。 + +## 三、为什么这是个问题 + +1. **接口没有告诉读者哪些方法与他有关。** 一个新连接器作者(比如接一个纯 JDBC 源)拿到的服务入口有 19 个方法,其中 11 个是他这辈子都用不到的存储与 BE 概念,而接口本身没有任何分组或说明。这条工作线的目标是「照着接口定义就能清晰实现」,19 个方法一锅端是这个目标的直接障碍。 +2. **每加一个存储服务,要改的地方是 4 处而不是 2 处。** 加一个存储方法今天必须动:接口、引擎实现、iceberg 包装类、paimon 包装类。任务 06 把后两处收成一处转发基类之后是 3 处。收成独立服务对象之后是 **2 处**(新接口 + 引擎实现),包装类与转发基类完全不必再动。 +3. **钉桩包装类的表面积越大,漏钉桩的概率越高。** 今天两个包装类逐方法手抄,存储那 11 个占了它们正文的一半以上。把这 11 个搬进一个子对象后,包装类对存储的转发从 11 个方法塌成 1 个取得器——**结构上不可能再漏**,也顺带把任务 06 里那个 `getFileSystem` 缺口在存储侧永久消灭。 +4. **`sanitizeJdbcUrl` 的问题是双重的**:名字把一个通用的出站地址检查钩子写成了 JDBC 专有(中立接口不该出现协议名),契约又用 MUST 承诺了一件没有强制点、且实测只有 1 个连接器遵守的事。读者按字面理解会以为 FE 侧所有外部连接都过了这道检查,实际不是。 + +**注意这不是正确性缺陷**:改完之后没有任何一条查询的结果会变化,收益是接口可读性与「以后改动只需碰 2 处」。这也是它排在最后的原因——**收益不紧急,代价(重部署冒烟)不小**。 + +## 四、用一个最小例子说明 + +假设明天要给引擎加一个存储服务:`getStorageStats(String location)`(返回某个目录的大小,用于统计)。 + +| 时间点 | 我必须改哪些文件 | 漏改的后果 | +|---|---|---| +| 今天 | ① `ConnectorContext`(加带默认实现的方法)② `DefaultConnectorContext`(真实现)③ iceberg `TcclPinningConnectorContext`(加一行转发)④ paimon `TcclPinningConnectorContext`(加一行转发) | 漏了 ③ 或 ④ **不报编译错**:这两个连接器静默拿到接口默认值,且这次调用没有类加载器钉桩 | +| 任务 06 合入后 | ① 接口 ② 引擎实现 ③ `ForwardingConnectorContext`(转发基类,一处) | 漏了 ③ 会被基类单测抓住 | +| **本任务合入后** | ① `ConnectorStorageContext` ② `DefaultConnectorContext` | 包装类与转发基类**不需要动**:它们只转发一个 `getStorageContext()`,存储服务的增删与它们无关 | + +`sanitizeJdbcUrl` 那一半用一段 SQL 就能说清。两条语句里的地址形态完全一样: + +```sql +-- 甲:走 fe-connector-jdbc。地址在建连前经过引擎的出站地址检查,内网地址会被拒。 +CREATE CATALOG c1 PROPERTIES ( + "type" = "jdbc", + "jdbc_url" = "jdbc:mysql://10.0.0.5:3306/db", ...); + +-- 乙:走 fe-connector-paimon 的 jdbc 目录。地址被原样交给 paimon 的 JdbcCatalogFactory 建连。 +CREATE CATALOG c2 PROPERTIES ( + "type" = "paimon", + "paimon.catalog.type" = "jdbc", + "uri" = "jdbc:mysql://10.0.0.5:3306/db", ...); +``` + +| 接口文档说了什么 | 实际发生什么 | 应该怎么写 | +|---|---|---| +| 「使用任何 JDBC 地址建连之前**必须**调用」 | 甲调用了;乙没有(第三方 SDK 内部建连,连接器手里没有建连时机) | 「连接器**自行**建立连接时必须调用;第三方 SDK 内部建连不在本钩子覆盖范围内」 | +| 方法名叫 `sanitizeJdbcUrl` | 引擎实现做的是通用出站地址安全检查,与 JDBC 协议无关 | 改成中立名 `sanitizeOutboundUrl` | + +## 五、解决方案 + +### 5.1 目标状态 + +**第一步(主体)**:在 `fe-connector-spi` 新增 `ConnectorStorageContext`,把上面表格里那 11 个方法**连 javadoc 一起原样搬过去**(一个字不改,只改 `{@link}` 的目标),保留它们现有的默认实现;`ConnectorContext` 只留一个取得器: + +```java +public interface ConnectorStorageContext { + + /** 什么都不管的默认实现:目录没有存储机制时用它,语义与今天各方法的默认值逐字一致。 */ + ConnectorStorageContext NOOP = new ConnectorStorageContext() { }; + + default Map vendStorageCredentials(Map rawVendedCredentials) { … } + default String normalizeStorageUri(String rawUri) { … } + default String normalizeStorageUri(String rawUri, Map rawVendedCredentials) { … } + default UnaryOperator newStorageUriNormalizer(Map rawVendedCredentials) { … } + default String getBackendFileType(String rawUri, Map rawVendedCredentials) { … } + default List getBrokerAddresses() { … } + default Map getBackendStorageProperties() { … } + default void testBackendStorageConnectivity(int storageBackendTypeValue, + Map backendProperties) throws Exception { … } + default List getStorageProperties() { … } + default FileSystem getFileSystem(ConnectorSession session) { … } + default void cleanupEmptyManagedLocation(String location, List tableChildDirs) { … } +} + +public interface ConnectorContext { + // …目录身份 / 环境变量 / HTTP 钩子 / 出站地址消毒 / 认证 / 失效通知 / 兄弟连接器工厂… + + /** + * 本目录的存储与 BE 侧服务。目录不由引擎管理存储时返回 {@link ConnectorStorageContext#NOOP} + * (不返回 null)。返回值在目录生命周期内是稳定的,连接器可以在构造时取一次存下来。 + */ + default ConnectorStorageContext getStorageContext() { + return ConnectorStorageContext.NOOP; + } +} +``` + +`NOOP` 常量沿用既有先例:本模块的 `ConnectorMetaInvalidator.java:34`,以及 `fe-connector-api` 的 `ConnectorHttpSecurityHook.java:56`(后者归属哪个模块本身未定,见 5.3,这里只借它的写法)。 + +**引擎侧不搬代码**:`DefaultConnectorContext` 改成 `implements ConnectorContext, ConnectorStorageContext, Closeable`,并加一个 `getStorageContext() { return this; }`。这样: + +- `fe-core` 里 11 个方法的实现体、5 个字段、`close()` 与文件系统的生命周期绑定**一行都不动**(避免把 `:330-384` 那段有锁有关闭标记的代码搬来搬去); +- `fe-core` 的 7 个 `DefaultConnectorContext*Test` 一行都不用改(它们用具体类型); +- 完全符合「`fe-core` 只出不进」:新增的只有两处签名接线,约 6 行。 + +**钉桩包装类怎么处理**:任务 06 合入后,两个 `TcclPinningConnectorContext` 已经继承 `ForwardingConnectorContext`、正文里没有任何存储转发。本任务只需在**转发基类**里把 11 个转发换成 1 个 `getStorageContext()` 转发。语义与今天逐字等价——今天这 11 个转发全是不带钉桩的纯直通(两个类的注释明确说明了原因),改完之后连接器直接拿到引擎的存储上下文,中间没有装饰层,行为一致。 + +基类 javadoc 里要补一句残留风险:**如果将来某个存储方法需要钉桩,钉桩子类必须自己包一层存储上下文**(把 `getStorageContext()` 覆写成返回一个自己的装饰器)。今天没有这种方法,所以不预先造这层包装。 + +**第二步(顺带)**:`sanitizeJdbcUrl` → `sanitizeOutboundUrl`,并按第四节表格重写 javadoc。它**留在 `ConnectorContext` 上**,不进 `ConnectorStorageContext`(跟存储无关),也**不能挪进 `ConnectorValidationContext`**——那个接口(`fe-connector-api/.../ConnectorValidationContext.java:31`,6 个方法全抽象)只在建目录校验期存在,而这个钩子是运行时创建客户端时以方法引用形式传给长生命周期客户端的(`JdbcDorisConnector.java:241` → `JdbcConnectorClient.java:182`),校验期上下文早已消失。 + +### 5.2 改动清单 + +| 文件 | 要做什么 | +|---|---| +| `fe-connector-spi/.../spi/ConnectorStorageContext.java` | **新增**。11 个方法 + javadoc 从 `ConnectorContext:151-415` 原样搬入;加 `NOOP` 常量;类注释说明「这是引擎实现、连接器消费的存储侧服务,新增存储服务加在这里,不要加回 `ConnectorContext`」 | +| `fe-connector-spi/.../spi/ConnectorContext.java` | 删掉 `:151-415` 那 11 个方法;加 `getStorageContext()` 默认返回 `NOOP`;`sanitizeJdbcUrl` 改名 `sanitizeOutboundUrl` 并重写契约段 | +| `fe-connector-spi/.../spi/ForwardingConnectorContext.java`(任务 06 产出) | 11 个存储转发换成 1 个 `getStorageContext()` 转发;`sanitizeJdbcUrl` 转发跟着改名;类注释补「存储方法若将来需要钉桩,子类须包装存储上下文」 | +| `fe-connector-spi` 的 `ConnectorContextTest.java` | `:47`(存储配置默认空)与 `:58`(BE 文件类型默认按 scheme 推导)两组断言移到新增的 `ConnectorStorageContextTest`;`createSiblingConnector` 那组留在原处 | +| `fe-core/.../connector/DefaultConnectorContext.java` | 类声明加 `ConnectorStorageContext`;加 `getStorageContext(){ return this; }`;`sanitizeJdbcUrl` 改名。**不搬任何逻辑** | +| `fe-connector-hive`(4 个文件 14 处)、`fe-connector-iceberg`(4 个文件 14 处)、`fe-connector-paimon`(2 个文件 4 处)、`fe-connector-hudi`(1 个文件 3 处) | 调用点改成经存储上下文调用。调用点 ≥3 处的文件加一个私有取得器(如 `private ConnectorStorageContext storage() { return context.getStorageContext(); }`),把 `context.getFileSystem(session)` 写成 `storage().getFileSystem(session)`。**现有的 `context != null ? … : …` 判空保持不动**(`HiveScanPlanProvider:619`、`HudiScanPlanProvider:424`、`PaimonScanPlanProvider:735`、`IcebergScanPlanProvider:1464` 这四处是离线单测走的分支) | +| `fe-connector-jdbc/.../JdbcDorisConnector.java:241` | 方法引用改成 `context::sanitizeOutboundUrl`(`JdbcConnectorClient` 侧的参数名/注释顺带改成中立措辞) | +| 3 个 `RecordingConnectorContext`(hive/iceberg/paimon)+ 6 处内联匿名替身 | 让替身同时实现 `ConnectorStorageContext` 并 `getStorageContext(){ return this; }`;覆写的存储方法原地不动 | + +**顺序建议**(每一步都能独立 `test-compile` 通过):先加新接口并让 `ConnectorContext` 的 11 个方法暂时保留为「转调 `getStorageContext()`」的过渡默认实现 → 逐个连接器迁调用点与测试替身 → 最后从 `ConnectorContext` 删掉这 11 个方法并收拾转发基类 → 独立一个 commit 做改名。 + +**这次迁移是编译期强制的**(与任务 06 那个静默缺口相反):接口方法一删,所有测试替身上的 `@Override` 立刻编译失败,编译器会把每一处需要复查的替身点出来。已核实 9 处替身全部标了 `@Override`。 + +### 5.3 明确不要顺手做的事 + +- **不要把 `DefaultConnectorContext` 真的拆成两个类。** 那 300 多行搬家会把带锁的文件系统缓存与 `close()` 生命周期一起搬走,风险与本任务的收益(SPI 表面可读性)不成比例;`fe-core` 的内部结构也不是这条工作线的目标。让它同时实现两个接口、返回 `this` 就够了。 +- **不要顺手给存储方法加类加载器钉桩。** 现有两个包装类明确写了这些方法完全跑在引擎侧、不需要钉桩。无差别加钉桩会改变现有行为并带来无谓开销。 +- **不要顺手把 `getHttpSecurityHook` 和改名后的出站地址消毒再收成第三个服务对象。** 只有 2 个方法,收益不足;`getHttpSecurityHook` 的归属(在 `api` 还是 `spi`)是另一条任务的事。 +- **不要顺手给 iceberg / paimon 的 REST / JDBC 目录补上出站地址检查。** 那是一项独立的安全增强(要动第三方 SDK 的建连路径),与上游 master 行为一致、不是本次迁移的回退;混进本任务会让「行为不变」这个验收前提失效。 +- **不要顺手删 `getMetaInvalidator`**(任务 14)**或改动本次范围外的默认值政策**(任务 07)。本任务只搬位置 + 一次改名。 +- **不要为「存储方法是否搬齐」写 shell / 正则门禁。** 本仓库已有结论:这类门禁只适合存在性与前缀类不变量。这里编译器本身就是门禁。 +- **不要变动 `ConnectorPluginManager.java:60` 的 `CURRENT_API_VERSION`。** 它至今是 1,从未随任何一次 SPI 改动递增;本工作线里 10 / 11 / 13 / 14 号任务同样在改 SPI 表面。真正的保障是「FE 与插件包一起重新构建、一起部署」,见第六节。 + +## 六、怎么验证 + +### 6.1 编译(最强的单一符号级信号) + +```bash +# 全反应堆含测试源编译,禁止跳过测试编译 +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T1C test-compile +``` + +这一步同时承担「有没有漏改测试替身」的检查:接口方法删掉后任何遗留的 `@Override` 都会失败。 + +### 6.2 单元测试 + +```bash +mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml \ + -pl fe-connector/fe-connector-spi,fe-connector/fe-connector-hive,fe-connector/fe-connector-iceberg,\ +fe-connector/fe-connector-paimon,fe-connector/fe-connector-hudi,fe-connector/fe-connector-jdbc,fe-core \ + -Dmaven.build.cache.enabled=false test +``` + +(必须禁用 build cache,否则 surefire 会被静默跳过、`BUILD SUCCESS` 是空的。) + +要断言到的东西: + +1. **新接口的默认值逐字不变**:`ConnectorStorageContextTest` 断言 `NOOP` 的 11 个方法返回值与今天 `ConnectorContext` 的默认值一致——尤其 `getBackendFileType` 按 scheme 推导的四条分支(`hdfs`/`viewfs` → `FILE_HDFS`、`file` 与无 scheme → `FILE_LOCAL`、其余 → `FILE_S3`),这些断言直接从 `ConnectorContextTest:58` 搬过来。 +2. **`getStorageContext()` 不返回 null**:断言未覆写它的匿名上下文返回 `NOOP`,让连接器侧不需要判空。 +3. **转发基类**:任务 06 建立的反射驱动单测继续跑通,并新增一条——空子类的 `getStorageContext()` 返回的是被包装上下文给出的那个实例(不是 `NOOP`)。这条要做**变异验证**:手工删掉基类里的 `getStorageContext()` 转发,确认测试失败并指出方法名,然后恢复。 +4. **两个钉桩包装类的现有断言全部保留不改**:钉桩生效、任务抛异常时恢复调用方加载器、非 Kerberos 走被包装上下文的认证、Kerberos 走插件侧 `doAs`、`createSiblingConnector` 转发给原始上下文。它们是「认证与钉桩语义没被继承结构调整改坏」的证据。 +5. **测试替身迁移不能把断言弱化**:替身改成实现两个接口后,逐个确认原断言仍然**能失败**——挑 3 处代表(hive 的 `getFileSystem`、iceberg 的 `getBackendFileType`、paimon 的 `normalizeStorageUri`),临时把替身的返回值改错,确认对应测试红。这一步是必须的:如果某个替身漏了 `getStorageContext()` 却仍编译通过(它没覆写过存储方法的情况),连接器会拿到 `NOOP`,某些断言可能从「验证归一化结果」退化成「验证原样返回」而依旧变绿。 +6. **改名**:全仓复扫 `sanitizeJdbcUrl` 命中数必须为 0(今天有 7 处:接口、引擎实现、两个包装类各 2 行、jdbc 调用点各 1 行)。 + +### 6.3 插件包重部署冒烟(本任务的核心把关,不可省) + +原因:改的是 parent-first 前缀(`ConnectorPluginManager.java:64-65` 声明 `org.apache.doris.connector.` 与 `org.apache.doris.filesystem.` 走 parent-first)内的接口。FE 与插件包必须**一起重建、一起部署**;混用旧插件 zip 会在运行时报 `AbstractMethodError` / `NoSuchMethodError` 而不是启动期拒绝。 + +步骤: + +1. `mvn -f …/fe/pom.xml package`,取各插件模块 `target/doris-fe-connector-.zip`(由 `src/main/assembly/plugin-zip.xml` 生成); +2. 清空并重新解包到 `connector_plugin_root`(默认 `${DORIS_HOME}/plugins/connector`),确认目录里没有上一版残留的 jar; +3. 重启 FE,日志里确认 `ConnectorPluginManager initialized … registered types: [...]` 列出全部类型; +4. 至少跑通下面这几条(每条都覆盖一类被改到的存储服务): + +| 冒烟项 | 覆盖什么 | +|---|---| +| iceberg 目录 `INSERT`(对象存储 warehouse) | 写路径的 BE 文件类型 + 地址归一 + 静态凭证;**同时是 iceberg-aws 按类名反射建 S3 客户端的那条路**,钉桩失效会在这里 `ClassCastException` | +| iceberg `DROP TABLE`(HMS 托管位置) | 空目录清理 | +| iceberg Kerberos 目录的一次读 + 一次写 | 钉桩与「连接器单一认证方」语义,这是唯一活的端到端认证把关点 | +| paimon 目录一次带临时凭证的扫描(REST 目录) | 临时凭证归一 + 批量地址归一器 | +| hive 目录一次分区表扫描 + 一次 `INSERT` | 引擎文件系统(今天唯一真在用它的连接器,14 处调用点里 6 处是它) | +| hudi 目录一次扫描 | BE 存储属性 + 地址归一 | +| `CREATE CATALOG … "test_connection" = "true"`(iceberg,S3 warehouse) | BE 连通性探测 | + +5. 观察 FE 日志无 `ClassCastException` / `NoClassDefFoundError` / `AbstractMethodError`。 + +### 6.4 端到端回归 + +本任务不改变任何运行时行为,端到端只作兜底。跑受影响的四个连接器的既有 `external_table_p0` / `external_table_p2` 子集(hive、iceberg、paimon、hudi)与 jdbc 的目录用例(改名影响它的建连路径)。**不需要新增端到端用例。** + +## 七、风险与回退 + +- **最大风险:钉桩失效但单测全绿。** 缓解只有一条——6.3 的重部署冒烟必须真跑,尤其 iceberg 的写入与 Kerberos 两项。单测能证明「转发到位」,不能证明「反射加载落在插件侧」。 +- **风险:测试替身静默弱化断言。** 见 6.2 第 5 条,用挑样变异验证兜住。这是本任务最容易出现的隐性质量损失。 +- **风险:混合部署(新 FE + 旧插件 zip)。** 表现为运行时 `AbstractMethodError`。缓解:冒烟前清空 `connector_plugin_root`;在 commit 信息里写明「插件包必须与 FE 同版本部署」。 +- **风险:与任务 06 的顺序倒置。** 若在 06 之前做,本任务要在两个包装类里各手改 11 处转发,风险显著上升且要重复两遍相同的判断。**06 未合入就不要开工。** +- **风险:与任务 14 撞车。** 14 号删 `getMetaInvalidator`,同样改转发基类。两者不冲突(一个删非存储方法,一个搬存储方法),但**不要合在一个 commit 里**,否则冒烟出问题时无法二分。 +- **回退**:改动全部是结构性的(搬位置 + 改名 + 调用点替换),无数据格式、无持久化、无 thrift 有线格式牵连(新接口保持零 thrift:BE 文件类型仍返回枚举名字符串,broker 地址仍用中立的 `ConnectorBrokerAddress`)。直接 revert 相应 commit 即可,但**必须连同插件包一起回退重部署**。 + +## 八、相关背景 + +- 调研报告 `../audit-report.md`: + - 第 6.1 节的接口规模表中 `ConnectorContext` 那一行——415 行 / 19 个方法 / 9 类能力;以及第 6.3 节建议里「`ConnectorContext` 把存储相关的方法收成一个服务对象,**这一批高危**,必须做插件包重部署冒烟」那一条; + - 第十五节整治路线表的第 11 批「装配上下文拆分」——风险标「高」,原因写明必须做插件包重部署冒烟以验证线程上下文类加载器的钉法; + - 附录 A 第 106 条(大杂烩接口,19 方法 9 类能力)、第 117 条(`sanitizeJdbcUrl` 契约只有一个连接器遵守,判定「成立」)、第 32 条(协议命名的中立性问题,判定「部分成立」,建议改名)、第 125 条(HTTP 安全钩子有同类的契约过宽问题,但明确不是迁移引入的回退); + - 附录 D.2(两个钉桩包装类的转发缺口机理)——那是任务 06 的来源,也是本任务风险评级的依据。 +- 同一任务空间:**任务 06**(转发基类,本任务的前置)、任务 07(把公共模块的设计规则写下来,包括「新增存储服务加在哪里」这条应写进新接口所在模块的包级说明)、任务 14(删推模型失效接口,同改转发基类,排在本任务前后皆可但不要同 commit)。 +- 项目记忆:`catalog-spi-plugin-tccl-classloader-gotcha`(四个已修的类加载器分裂位置,解释为什么钉桩相关改动必须重部署验证)、`fe-core-source-isolation-iron-rules`(`fe-core` 只出不进,本任务据此选择「引擎实现类同时实现两个接口」而不是搬代码)、`static-gate-only-for-existence-not-language-semantics`(为什么不写静态门禁)、`doris-build-verify-gotchas`(maven 绝对路径 `-f`、后台任务退出码的读法)。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/24-decision-connector-declared-properties.md b/plan-doc/connector-public-interface-cleanup/tasks/24-decision-connector-declared-properties.md new file mode 100644 index 00000000000000..86e97d2da1a2e8 --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/24-decision-connector-declared-properties.md @@ -0,0 +1,252 @@ +# 24. 待拍板:连接器自声明属性——接线还是删除 + +> **优先级**:待用户拍板(决定之后才知道归入哪一批) | **风险**:选项一 高 / 选项二 低 | **前置依赖**:无。但**它反过来卡住 11 号任务**——11 号任务改动清单的第 1 项就是删掉本文讨论的这三个死接口,本文如果拍成「接线」,11 号必须把那一项摘出去。 +> **影响模块**:选项二只动 `fe-connector-api`(删 1 个类 + 2 个默认方法)与 `fe-core`(**仅测试**,删两行断言);选项一要动 `fe-connector-api`、`fe-core`(新增引擎侧校验与可能的新语法)、以及每个想声明属性的连接器。 +> **预计改动规模**:选项二约 3 个文件、净减约 130 行;选项一保守估计 15~25 个文件、净增 600 行以上(不含语法层)。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +公共接口 `fe-connector-api` 里躺着一套「连接器自己声明它接受哪些配置项」的机制(`ConnectorPropertyMetadata` 这个类,加上 `Connector` 上的两个取得器),它是从 Trino 直译过来的,在 Doris 这边**一个实现、一个调用点都没有**;本文把「把它接线成真机制」和「删掉它」两条路的代价、影响面、以及它到底能不能解决我们真正的痛点摊开,请你拍板选一条——**不允许的第三条是维持现状,让它继续在公共接口里当装饰**。 + +## 二、背景:现在的代码是怎么写的 + +### 2.1 这三个接口长什么样 + +`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPropertyMetadata.java`,共 120 行,一个泛型不可变值对象,字段是 `name` / `description` / `type` / `defaultValue` / `required`(`:29-33`),对外只给四个静态工厂:`stringProperty`(`:46`)、`intProperty`(`:53`)、`booleanProperty`(`:60`)、`requiredStringProperty`(`:67`),另有 getter、`equals`、`hashCode`、`toString`。 + +`Connector.java:234-242` 上挂着两个默认方法把它返回出来: + +```java +/** Returns the table-level property descriptors. */ +default List> getTableProperties() { + return Collections.emptyList(); +} + +/** Returns the session-level property descriptors. */ +default List> getSessionProperties() { + return Collections.emptyList(); +} +``` + +全仓库对 `ConnectorPropertyMetadata` 这个类名只有 15 处命中:13 处在它自己的文件里,2 处就是上面那两行返回类型。本仓库的 8 个连接器(es / hive / hudi / iceberg / jdbc / maxcompute / paimon / trino)一个都没有覆写这两个方法。两个方法唯一的调用点在一个测试里——`fe/fe-core/src/test/java/org/apache/doris/connector/fake/FakeConnectorPluginTest.java:177-178`,断言的内容正是「它俩返回空」: + +```java +Assertions.assertTrue(connector.getTableProperties().isEmpty()); +Assertions.assertTrue(connector.getSessionProperties().isEmpty()); +``` + +在 Trino 里同名机制是活的:连接器声明一批属性描述符,引擎据此校验 `CREATE TABLE ... WITH (...)` 的键是否合法、支持 `SET SESSION 目录.属性 = 值`,还能通过系统表把「这个目录接受哪些旋钮」列出来给用户看。连接器加一个可调参数完全不碰引擎。 + +### 2.2 撞名警告:仓库里有三个 `get*Properties`,含义各不相同 + +在讨论之前必须先把名字理清,否则一定会误判: + +| 符号 | 位置 | 返回 | 状态 | +|---|---|---|---| +| `Connector.getTableProperties()` | `Connector.java:235` | `List>` | **本文讨论的死接口** | +| `PluginDrivenExternalTable.getTableProperties()` | `fe-core/.../datasource/plugin/PluginDrivenExternalTable.java:768` | `Map` | **活的**,是 `SHOW CREATE TABLE` 渲染 `PROPERTIES(...)` 的数据源(`Env.java:4881` 消费) | +| `Connector.getSessionProperties()` | `Connector.java:240` | `List>` | **本文讨论的死接口** | +| `ConnectorSession.getSessionProperties()` | `ConnectorSession.java:89` | `Map` | **活的且用得很重**,hive / iceberg / paimon / hudi 都在读它取会话变量 | + +删除本文这两个方法,跟上面两个活的同名方法毫无关系;反过来说,看到 grep 里一堆 `getSessionProperties` 命中就以为它是活的,也是错的。 + +### 2.3 Doris 今天真实存在的三条属性通道 + +**通道一:按目录的属性(已经是连接器完全自有的,公共模块零改动)。** `ConnectorProvider.create(Map properties, ConnectorContext context)`(`fe-connector-spi/.../ConnectorProvider.java:64`)把 `CREATE CATALOG ... PROPERTIES(...)` 的整张属性表原样交给连接器;同一张表也通过 `ConnectorSession.getCatalogProperties()`(`ConnectorSession.java:78`)在查询期可见。校验钩子也已经在连接器一侧:`ConnectorProvider.validateProperties(Map)`(同文件 `:74`,默认空实现),fe-core 在 `PluginDrivenExternalCatalog.java:212` 经 `ConnectorFactory.validateProperties` 调它,hive / iceberg / trino 三个连接器已经覆写。 + +这条通道**今天已经在被当作「连接器私有旋钮」用**,三个键为证,键名前缀、解析、默认值全在 hive 连接器里,fe-core 全树对这三个键字符串零命中: + +| 键 | 声明处 | 读取处 | +|---|---|---| +| `hive.ignore_absent_partitions` | `HiveConnectorProperties.java:116` | `HiveScanPlanProvider.java:544-545` | +| `hive.enable_hms_events_incremental_sync` | `HiveConnectorProperties.java:102-103` | `HiveConnector.java:459-460` | +| `hive.hms_events_batch_size_per_rpc` | `HiveConnectorProperties.java:106` | `HiveConnector.java:466-468` | + +**通道二:FE 全局配置(`fe.conf`)→ 逐键手工转发。** `fe/fe-core/src/main/java/org/apache/doris/connector/DefaultConnectorContext.java:568-596` 的 `buildEnvironment()` 把 9 个键塞进一张 map,连接器经 `ConnectorContext.getEnvironment()` 读回。其中 7 个是数据源专属的: + +| 环境键 | 转发处 | `fe.conf` 字段 | 归属 | +|---|---|---|---| +| `jdbc_drivers_dir` | `:574` | `Config.java:157` | jdbc(iceberg/paimon 的 jdbc 元存储也用) | +| `force_sqlserver_jdbc_encrypt_false` | `:575-576` | `Config.java:176` | jdbc(连数据库品牌都写进键名了) | +| `jdbc_driver_secure_path` | `:577` | `Config.java:163` | jdbc | +| `hive_metastore_client_timeout_second` | `:581-582` | `Config.java:2140` | hive 元存储 | +| `hive_default_file_format` | `:587` | `Config.java:2561` | hive 建表 | +| `enable_create_hive_bucket_table` | `:588` | `Config.java:2558` | hive 建表 | +| `trino_connector_plugin_dir` | `:595` | `Config.java:2895` | trino | + +另外两个 `doris_home`(`:572`)与 `doris_version`(`:591`)是中立的部署信息,不在讨论范围。`:586` 那条注释明写着这条通道的脆弱点: + +``` +// Keys must stay byte-identical to the reads in HiveConnectorProperties. +``` + +对应的读取端确实是逐字抄的常量:`HiveConnectorProperties.java:77-79` 的 `ENV_HIVE_DEFAULT_FILE_FORMAT` / `ENV_ENABLE_CREATE_HIVE_BUCKET_TABLE` / `ENV_DORIS_VERSION`。改错一个字母不报编译错,只会静默取默认值。 + +**通道三:会话变量。** `fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java:222-233` 的 `extractSessionProperties`:主体是 `VariableMgr.toMap(ctx.getSessionVariable())`(`:223`,整表倒出,不维护白名单,所以**新增一个会话变量本身不需要改这里**),后面另有两个手工 `put`: + +- `lower_case_table_names`(`:225-226`):是注册过的变量,但作用域是全局(`fe-common/.../qe/GlobalVariable.java:109-110`,`GLOBAL | READ_ONLY`),不在会话变量表里,所以要单独补; +- `max_compute_write_max_block_count`(`:231-232`):**根本不是变量**,是 `fe.conf` 字段 `Config.java:2190`,被塞进了这条「会话属性」通道,注释自己承认这是借道("same as lower_case_table_names above")。 + +这与取得器自己的文档冲突:`ConnectorSession.java:81-88` 的 javadoc 说这里装的是「来自用户会话的按查询设置(例如 SET 语句),键名取自 FE 会话变量注册表」。第二个键两条都不满足。 + +## 三、为什么这是个问题 + +**第一,死接口本身的代价。** 公共接口上的每个方法都是对 8 个连接器作者的一次要求。一个零实现零消费的方法,会让读接口的人以为「原来加旋钮该走这里」,照着做完发现无人读取;而 11 号任务清单里其它死接口的教训已经说明,留着不删的接口迟早会被人当真。 + +**第二,也是必须纠正最初那轮调研结论的一点:这套接口即使接线,也修不了我们真正的痛点。** 「今天所有数据源专属旋钮只能落到 `fe-common` 全局配置与 `fe-core` 会话变量这条封闭路径上」这句话**是过宽的**——通道一已经存在,`hive.ignore_absent_partitions` 这三个键就是「连接器加旋钮、公共模块零改动」的既成事实。真正必须改公共模块的只剩两种情形,而它们的成因都不是「缺少描述符」: + +- 旋钮的值住在 `fe.conf`(一个 `fe-common` 的部署文件),连接器无法 import `Config`,所以必须有人从 `fe-common` 搬到 map 里——搬运动作本身就是那行手工转发。补上描述符不会让这行消失,除非把旋钮**从 `fe.conf` 挪到目录属性**,而那才是真正的兼容变更。 +- 旋钮想按会话生效,而 Doris 的 `SET` 语法只认注册在 `SessionVariable` 里的变量名,没有「按目录设置连接器属性」这种语法(`ALTER CATALOG ... SET PROPERTIES` 有,`AlterCatalogPropertiesCommand.java:31-36`,但那是改持久化的目录属性,不是按会话覆盖)。 + +**第三,真正缺的那一块(声明式校验与可发现性)今天也已经有连接器侧入口。** 目录属性的键名今天**拼错不报错**:多写一个字母静默失效,用户只会看到「设了没用」。这确实是个缺陷,但补它并不需要描述符——`ConnectorProvider.validateProperties` 就是为此存在的钩子,连接器自己在里面比对键名即可,零公共模块改动。描述符能带来的额外价值只有「统一形状」和「引擎能把可用旋钮列出来给用户看」,而后者要新增一张 fe-core 系统表,与「fe-core 只出不进」的现阶段纪律正面相撞。 + +**用户可见后果**:现状本身没有正确性缺陷。选项一如果做,会有用户可见后果(配置项位置变化);选项二没有。 + +## 四、用一个最小例子说明 + +场景:**hive 连接器作者想加一个旋钮,控制「列举分区目录时用几个线程」。** + +先看三种作用域今天各要动什么: + +| 我希望这个旋钮怎么设 | 今天必须动的文件 | 涉及模块数 | +|---|---|---| +| 按目录(写在 `CREATE CATALOG` 里) | 只有 `HiveConnectorProperties.java`(加常量)+ 读取处 | **1 个(连接器自己)** | +| 全 FE 一份(写在 `fe.conf` 里) | `fe-common/Config.java` 加字段、`DefaultConnectorContext.buildEnvironment` 加一行转发、连接器加常量与读取 | 3 个 | +| 按会话(`SET`) | `fe-core/SessionVariable` 加字段(`VariableMgr.toMap` 会自动带上)、连接器加常量与读取 | 2 个 | + +也就是说,**「零公共模块改动」这个世界在按目录这一档已经到手了**: + +```sql +-- 今天就能这么用:键名、默认值、解析全在 hive 连接器里,fe-core 完全不知道这个键存在 +CREATE CATALOG hive_a PROPERTIES ("type" = "hms", "hive.ignore_absent_partitions" = "false"); +ALTER CATALOG hive_a SET PROPERTIES ("hive.hms_events_batch_size_per_rpc" = "1000"); +``` + +今天做不到的是这两件事: + +```sql +-- ① 拼错键名:少写一个 s,语句成功、没有任何提示,旋钮静默失效 +CREATE CATALOG hive_b PROPERTIES ("type" = "hms", "hive.ignore_absent_partition" = "false"); + +-- ② 按会话临时覆盖某个目录的连接器旋钮:Doris 没有这个语法,直接报语法错误 +SET SESSION hive_a.ignore_absent_partitions = false; +``` + +两个选项各自能带来什么: + +| | 选项一(接线成声明式属性) | 选项二(删掉三个死接口) | +|---|---|---| +| 上表第一行「按目录」 | 已经零改动,接线后再加一行描述符声明 | 不变,仍然零改动 | +| ① 拼错键名静默失效 | 引擎按描述符统一拒绝未知键 | 仍可修,但走 `validateProperties`,由连接器自己拒 | +| ② 按会话覆盖目录旋钮 | 需要**同时**新增 SQL 语法与 fe-core 解析,描述符只是其中一环 | 不做 | +| 那 7 个 `fe.conf` 键的手工转发 | **不会消失**,除非把键搬到目录属性(兼容变更) | 不变 | + +## 五、解决方案 + +### 5.1 目标状态 + +**选项一:接线成 Trino 那样的声明式属性。** + +保留 `ConnectorPropertyMetadata`,把两个取得器接成真通道。要成立至少需要三件事同时落地: + +1. **描述符成为目录属性的合法键来源**。签名可以不变,但语义要从「表级/会话级」纠正为「目录级」,因为 Doris 的对应物是 `CREATE CATALOG ... PROPERTIES`: + ```java + /** 本连接器接受的目录级属性;引擎据此拒绝未知键并填默认值。 */ + default List> getCatalogPropertyMetadata() { + return Collections.emptyList(); + } + ``` + 注意:校验发生在 `CREATE CATALOG`,那时 `Connector` 实例还没建好,只有 `ConnectorProvider`,所以这个取得器**必须挂在 `ConnectorProvider` 上而不是 `Connector` 上**——这意味着两个现有方法本来的位置就是错的,接线也要搬家。 +2. **未知键的处置要是可开关的**。存量目录里可能已经存了拼错的或第三方工具塞进去的键,直接改成硬拒会让 FE 重启后加载不了既有目录。必须是「先告警、可配置升级为拒绝」的两段式。 +3. **兼容承诺**:那 7 个 `fe.conf` 键一个都不能改名、不能失效。如果同时想把它们变成目录属性,只能是「目录属性优先、缺失时回落到 `fe.conf` 值」的叠加语义,`fe.conf` 键至少保留若干个版本并在文档标记为过时。 + +至于「按会话覆盖某目录的属性」(`SET SESSION 目录.属性`),需要新增 SQL 语法、解析、会话态存储与生命周期,**属于引擎语法层的独立工程,不应塞进这个决策里**;建议明确排除在本次范围之外。 + +**选项二:删掉三个死接口,把入口形状写进设计文档。** + +`ConnectorPropertyMetadata.java` 整文件删除,`Connector.java:234-242` 的两个默认方法删除,`FakeConnectorPluginTest.java:177-178` 两行断言删除(该测试方法其余断言保留)。同时在 7 号任务产出的包级说明里补一段「连接器旋钮该放哪」的规则:按目录的旋钮走 `CREATE CATALOG` 属性 + `ConnectorProvider.validateProperties` 自校验(首选);必须全 FE 一份的走 `fe.conf` + `buildEnvironment` 转发,并在两侧注释里互指键名;未来若要做声明式属性,正确的入口是 `ConnectorProvider` 上的目录级描述符取得器,而不是 `Connector` 上的表级/会话级取得器。 + +**我的推荐:选项二。** 理由三条。第一,这套接口即使接线也不解决我们真实的两个卡点(值住在 `fe.conf`、没有按会话覆盖语法),它承诺的东西和我们缺的东西不重合;第二,它现在挂错了位置——目录属性校验发生在 `Connector` 存在之前,照现有签名接线一定要重新设计,那已经是「新做一套」而不是「接线」,把死接口留在原地并不能省下这份设计;第三,它现在挡住的那个真缺陷(拼错键静默失效)有一个成本低得多的现成入口 `validateProperties`,可以独立立项,不需要引入引擎侧的属性校验框架。 + +如果你倾向选项一,我的建议是**先只做第 1 件事(目录级描述符 + 未知键告警)**,把 `fe.conf` 键的搬迁和按会话覆盖语法各自单独立项,避免一次改动同时动到用户可见的配置位置和 SQL 语法。 + +### 5.2 改动清单 + +**选项二(推荐)的改动清单:** + +| 序号 | 文件 | 做什么 | +|---|---|---| +| 1 | `fe-connector-api/.../api/ConnectorPropertyMetadata.java` | 整文件删除 | +| 2 | `fe-connector-api/.../api/Connector.java`(`234-242`) | 删两个默认方法;`List` / `Collections` 的 import 视文件内其它用法决定去留(该文件其它方法仍在用,预计都保留) | +| 3 | `fe-core/src/test/.../connector/fake/FakeConnectorPluginTest.java`(`177-178`) | 删两行断言,`connectorTopLevelDefaults` 其余断言保留 | +| 4 | 7 号任务的包级说明文档 | 补一段「连接器旋钮该放哪」的规则与「未来入口形状」的记录 | + +选完这条后,请把 11 号任务改动清单的第 1 项标注为「由 24 号任务落地」或反之,两者只做一次,避免重复删除造成冲突。 + +**选项一的改动清单(只列骨架,正式做之前需要单独出一份施工文档):** + +| 序号 | 文件 | 做什么 | +|---|---|---| +| 1 | `fe-connector-spi/.../spi/ConnectorProvider.java` | 新增目录级描述符取得器(默认空列表) | +| 2 | `fe-connector-api/.../api/Connector.java` | 删掉现有两个挂错位置的取得器 | +| 3 | `fe-connector-api/.../api/ConnectorPropertyMetadata.java` | 保留;`description` 的用途要落地(否则它仍是死字段) | +| 4 | `fe-core/.../connector/ConnectorPluginManager.java`(`161-170`) | 在既有 `validateProperties` 调用链上加一段「按描述符检查未知键」,默认只告警 | +| 5 | `fe-common/.../Config.java` | 新增一个开关,把未知键从告警升级为拒绝 | +| 6 | 各连接器的 `*ConnectorProperties` / `*ConnectorProvider` | 逐个把已有的目录属性键改写成描述符声明(hive 至少 3 个键,iceberg / paimon / jdbc / trino / es 待清点) | +| 7 | 各连接器与 `fe-connector-api` 的测试 | 新增「未知键被拒/被告警」「默认值生效」的断言 | +| 8 | 用户文档 | 说明新的校验行为与开关 | + +### 5.3 明确不要顺手做的事 + +- **不要顺手改那两个活的同名方法**(`PluginDrivenExternalTable.getTableProperties()`、`ConnectorSession.getSessionProperties()`)。前者是 `SHOW CREATE TABLE` 的数据源,后者 8 个连接器在读,与本文毫无关系。 +- **不要顺手清理 `buildEnvironment` 的 7 个转发键**。它们是迁移前既有的 `fe.conf` 名字,删任何一个都是用户可见的兼容破坏;把它们改成目录属性是独立议题,需要单独的兼容设计与文档。 +- **不要顺手把 `max_compute_write_max_block_count` 从会话通道搬到环境通道**——虽然它确实放错了地方(它是 `fe.conf` 字段却走了会话属性通道)。搬家会改变 maxcompute 连接器的读取位置,属于独立的一次修正,且要连带修 maxcompute 侧的读取常量与测试;本文只负责把这件事记录下来。 +- **不要在本任务里做「按会话覆盖目录属性」的 SQL 语法**。那是引擎语法层的独立工程,混进来会让这个决策无法评估。 +- **不要为「描述符必须被声明」写 shell 或正则静态门禁**。判断一个连接器是否声明了描述符属于语言语义范畴,本仓库已有结论:这类门禁误报比漏报更毒。 + +## 六、怎么验证 + +**选项二的验证:** + +1. **全反应堆含测试源编译**(唯一能一次证明「引用全清」的动作,禁止任何跳过测试编译的参数): + ``` + mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -T 1C test-compile + ``` + `BUILD SUCCESS` 之外任何 symbol not found 当场处理,不许注释掉测试绕过。 +2. **单测**(必须关掉构建缓存,否则测试会被静默跳过而仍报 `BUILD SUCCESS`): + ``` + mvn -f /mnt/disk1/yy/git/wt-catalog-spi/fe/pom.xml -Dmaven.build.cache.enabled=false \ + -pl fe-core test -Dtest=FakeConnectorPluginTest + ``` + 删掉两行断言后,`connectorTopLevelDefaults` 必须仍然覆盖 `getScanPlanProvider()` 为空、`getCapabilities()` 为空、`defaultTestConnection()` 为 false、`testConnection()` 成功这几条——如果删完只剩一两条,就是把测试意图一起删掉了。 +3. **删除彻底性自查**(人工一次,不做成门禁):全仓 grep `ConnectorPropertyMetadata` 期望零命中;grep `getTableProperties` / `getSessionProperties` 期望**只剩** 2.2 节表里那两个活的方法及其调用点。 +4. **端到端回归:不需要。** 删掉的路径在生产上恒为空列表,无任何运行时行为。 +5. **不需要变异验证。** 没有被保护的行为可供变异。 + +**选项一的验证(若拍这条,正式施工文档里要展开):** + +- 单测要断言的核心不变量是三条:声明过的键被接受并按 `defaultValue` 填默认;未声明的键在告警模式下**目录仍能创建成功**(这条最重要,它保护的是存量目录能被加载);开关打开后未声明的键被拒且报错文案里带上键名。 +- 必须做一次变异验证:手工把校验改成恒通过,「未知键被拒」那条用例必须变红;如果不红,说明测试没有真正走到校验。 +- 端到端回归是**硬性要求**(需本地集群):至少覆盖「用旧 `fe.conf` 键的既有目录重启 FE 后仍能加载并查询」,以及 hive 那三个既有目录属性键的行为不变。这是选项一与选项二在验证成本上的关键差别。 + +## 七、风险与回退 + +**选项二的风险:低。** 删的是恒空列表,运行时零行为。唯一的实质风险是**判断失误**——如果将来确实要做声明式属性,得重新写这个类。但 5.1 节的分析表明现有签名挂错了位置(校验时点没有 `Connector` 实例),将来那次工作无论如何都要重新设计入口,删除并不增加成本。回退就是 `git revert`,无数据、无持久化、无有线格式牵连。 + +**选项一的风险:高**,且集中在两处: + +- **存量目录加载**。如果未知键处置一步到位改成硬拒,任何一个既有目录里存着连接器没声明的键(历史遗留、拼错、第三方工具写入),FE 重启后就加载不了这个目录。这是「重启才炸」的类型,测试环境不一定复现。所以两段式(默认告警、开关升级为拒绝)不是可选项而是必需项。 +- **配置项位置的兼容承诺**。7 个 `fe.conf` 键一旦对外宣布「已改为目录属性」,就不能反悔。回退代价远高于代码回退——文档、用户脚本、运维手册都会跟着走。 + +无论选哪条,本文档的决定都要回写到 `plan-doc/connector-public-interface-cleanup/HANDOFF.md` 的待拍板段落与 `README.md` 的任务表,并同步调整 11 号任务的改动清单第 1 项。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` + - 第 4.5 节「一个尚未排期、需要先拍板的落点:连接器自声明属性」——本文的出处;注意其中「所有数据源专属旋钮只能落到全局配置与会话变量」的判断经本文核实**过宽**,按目录的通道已经存在。 + - 第 7.2 节「可以直接删」表格第一行——把这三个接口列入直接删除项,与本文选项二一致。 + - 附录 A 第 43 / 47 / 55 / 91 条——同一处发现的四次独立命中(属性描述符体系零实现零消费),其中 55 与 91 已经注意到了 2.2 节说的撞名问题。 +- `plan-doc/connector-public-interface-cleanup/tasks/11-delete-dead-surface-batch-one.md` 的改动清单第 1 项——**与本文选项二是同一件事,只能做一次**。 +- `plan-doc/connector-public-interface-cleanup/tasks/07-write-down-the-design-rules.md`——选项二要补的那段「连接器旋钮该放哪」的规则应落在这份包级说明里。 +- `plan-doc/connector-public-interface-cleanup/tasks/06-fix-engine-context-forwarding-gap.md`——同样在讨论 `ConnectorContext` 这条通道(那份讲的是转发漏抄,本文讲的是转发内容),两者不冲突。 diff --git a/plan-doc/connector-public-interface-cleanup/tasks/25-fix-earlier-review-doc-errors.md b/plan-doc/connector-public-interface-cleanup/tasks/25-fix-earlier-review-doc-errors.md new file mode 100644 index 00000000000000..fa158e8962d92e --- /dev/null +++ b/plan-doc/connector-public-interface-cleanup/tasks/25-fix-earlier-review-doc-errors.md @@ -0,0 +1,243 @@ +# 25. 修正同日另一份评审文档里的事实错误与结论 + +> **优先级**:收尾(随时可做,不阻塞任何代码任务) | **风险**:低 | **前置依赖**:无 +> **影响模块**:不涉及任何 maven 模块。只改 `plan-doc/` 下的一份 markdown 文档,不动一行 Java。 +> **预计改动规模**:1 个文件,约 35~45 行的就地修改(其中新增 3 行头部说明,其余是句子级替换)。 +> **行号说明**:本文行号以 `7ff51a106f0` 为准,核对时以符号名为准,不要以行号为准。 + +## 一、一句话说明这个任务要解决什么 + +同一天早些时候另一轮审查留下了 `plan-doc/connector-api-spi-design-review-2026-07-25.md`(689 行)。这份文档整体质量很高,但里面有 5 处经回到代码实测**不成立**的事实、3 条方向应当反转的结论;这些错误恰好落在「删什么 / 改什么名 / 把哪个常量搬到哪里」这类会被人照着动手的地方。本任务是把这 8 处就地改掉,并在文档头部加一句说明它与本任务空间的关系——不是用本轮的调研报告覆盖它。 + +## 二、背景:现在的代码是怎么写的 + +被修正的对象是文档而不是代码,所以这一节讲的是**那份文档写了什么**,以及**代码在 HEAD 上实际是什么样**。以下每一条都已在 `7ff51a106f0` 上用 grep / Read 核实过。 + +### 2.1 契约校验器到底有没有真实连接器在调用 + +那份文档第 529–531 行写: + +> 实际情况:全仓唯一的调用点是 `fe-core/src/test/.../ConnectorContractValidatorTest.java`,它用的是**手写的假连接器**,8 个真实连接器没有任何一个调用过它。 + +实测不是这样。除了 fe-core 那个假连接器测试,还有 4 个连接器的契约测试在真的构建自己的连接器并调用它: + +- `fe/fe-connector/fe-connector-es/src/test/java/org/apache/doris/connector/es/EsScanPlanProviderTest.java:332` +- `fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergConnectorTest.java:368` +- `fe/fe-connector/fe-connector-jdbc/src/test/java/org/apache/doris/connector/jdbc/JdbcDorisConnectorTest.java:187` +- `fe/fe-connector/fe-connector-maxcompute/src/test/java/org/apache/doris/connector/maxcompute/MaxComputeConnectorContractTest.java:66` + +再看校验器自己的 4 条规则(`fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorContractValidator.java:44`、`:49`、`:57`、`:65`)与谁声明了被检查的能力: + +| 校验器里的规则 | 涉及的能力位 | 唯一声明它的连接器 | 有没有真实连接器的正样本 | +|---|---|---|---| +| 分支写必须同时支持普通 INSERT | `supportsWriteBranch` | iceberg(`IcebergWritePlanProvider.java:327`) | 有(iceberg 契约测试) | +| 分区本地排序必须同时要求并行写 + 全 schema 顺序 | `requiresPartitionLocalSort` | maxcompute(`MaxComputeWritePlanProvider.java:160`) | 有(maxcompute 契约测试第 63 行还专门断言了这一条) | +| 分区哈希写必须同时要求并行写 + 全 schema 顺序 | `requiresPartitionHashWrite` | hive(`HiveWritePlanProvider.java:139`) | **没有**(hive 没有契约测试调用点) | +| 两个分区分布模式互斥 | 上面两个一起 | 只有 hive 会踩到 | **没有** | + +所以真实缺口很窄:**唯一声明分区哈希写的 hive 没有调用点**,因此涉及它的两条不变量缺一个真实连接器正样本。而校验器类注释(`:29-34`)说的「由各连接器的契约测试执行」是**部分已经实现**的机制,不是虚构的机制。 + +### 2.2 分片类型两个方法的实现者数量与默认值 + +那份文档第 211–212 行写「这两个方法没有默认实现或者默认值形同虚设,于是全部 7 个连接器都老老实实实现了 `getRangeType()`(es / hive / hudi / iceberg / jdbc / maxcompute / paimon)」,第 636–637 行的行动清单也写「顺带减少 7 个连接器的无效实现」。 + +实测:`getRangeType()` 是 8 个连接器都实现了,漏掉的是 trino——`fe/fe-connector/fe-connector-trino/src/main/java/org/apache/doris/connector/trino/TrinoScanRange.java:79`。 + +而两个方法的默认值情况并不对称: + +- `ConnectorScanRange.getRangeType()`(`fe-connector-api/.../scan/ConnectorScanRange.java:43`)是**接口抽象方法,没有默认实现**,所以 8 个分片类被迫逐个实现。 +- `ConnectorScanPlanProvider.getScanRangeType()`(`.../scan/ConnectorScanPlanProvider.java:52-53`)**有默认值** `FILE_SCAN`,只有 hive / es / jdbc 三家做了与默认值等价的多余覆写。 + +### 2.3 写侧分区规格并不存在「空规格=另一回事」 + +那份文档第 417 行写:`getWritePartitioning`「`null` = 未分区;空 spec = 另一回事」。 + +实测接口文档写得很清楚,而且给了理由(`fe-connector-api/.../write/ConnectorWritePlanProvider.java:107-109`): + +``` + *

      {@code null} (not an empty spec) means the target is unpartitioned, mirroring the legacy + * {@code spec().isPartitioned()} gate — the engine then falls back to its non-partitioned merge + * distribution. +``` + +唯一的生产者是 iceberg(`IcebergWritePlanProvider.java:285`),未分区表返回 `null`(有测试 `IcebergWritePlanProviderTest.java:664` 钉住);唯一的消费者是 `fe-core/.../PhysicalExternalRowLevelMergeSink.java:302`。全仓没有任何一处让「空规格」表示第三种含义。所以这是**虚构的第三态**——同一小节里另外两条(写排序列的 `null` vs 空 list、`hasConjunctTracking` 布尔位)是真的。 + +### 2.4 规模数字 + +| 那份文档写的 | 位置 | 实测 | +|---|---|---| +| `fe-connector-api` 约 9800 行 | 第 28 行 | 10149 行(95 个源文件这个数字是对的) | +| `Connector` 32 个方法 | 第 36 行 | 34 个 | +| `ConnectorSession` 14 个方法 | 第 39 行 | 15 个 | +| `ConnectorPartitionInfo` 8 个构造函数 | 第 688 行 | 6 个(同句里 `ConnectorType` 7 个构造函数是对的) | + +### 2.5 三条要反转的结论 + +1. **推模型的失效接口(第 475 行的建议:给两组同名反向接口之一改名)**——实测 `ConnectorMetaInvalidator` 这套「连接器 → 引擎」的推模型是死的:连接器 `src/main` 里除了 iceberg / paimon 两个类加载器钉桩包装类的透明转发,没有任何生产调用;引擎侧实现 `fe-core/.../ExternalMetaCacheInvalidator.java` 自己在注释里写着按分区失效履约不了(`:61-68` 降级成整表失效)、统计失效是空操作(`:71-77`)。既然一整套要删(另有一份《删除推模型的缓存失效接口》任务负责删代码),名字冲突自然消失,不需要给活着的那组(`Connector.invalidate*`)改名去动 8 个连接器。 +2. **分区空值哨兵(第 186 行、第 650 行的建议:`HIVE_DEFAULT_PARTITION` 下沉到 hive 连接器)**——实测不能下沉,三条硬约束:引擎自己的分区路径解析在用它(`fe-core/.../datasource/scan/FilePartitionUtils.java:143`);非 hive 连接器 paimon 主动把空分区归一成这个串(`PaimonConnectorMetadata.java:1263`);引擎侧已经存在第二份同串定义(`fe-core/.../datasource/TablePartitionValues.java:47`)。下沉只会变成三份,还会让 fe-core 反过来依赖 hive 插件。 +3. **压缩类型调整方法(第 164 行把它列进「源专有语义混入」,第 650 行要求「文档去 Hadoop 化」)**——实测 `adjustFileCompressType`(`ConnectorScanPlanProvider.java:125`)的方法名与默认值本来就是中立的(默认恒等),javadoc 里提 Hadoop / LZ4 的那段(`:117-121`)**恰恰是在解释为什么必须有这个钩子**,末句原文就是 `This keeps that hadoop-specific fact inside the connector; the generic node stays connector-agnostic.`——它是「通用节点不得出现数据源专有代码」这条规则的正面案例,不是违规。把这段说明删掉,只会让下一个维护者不知道这个钩子干什么、进而把 LZ4 重映射写回通用节点。 + +## 三、为什么这是个问题 + +这份文档不是随笔,它是会被人当施工依据用的:它的第五节就是一份编号 1–17 的行动清单。清单里的条目一旦照着做,后果是实打实的: + +- **会做重复劳动**:按「8 个连接器没有一个调用契约校验器」去给 8 个模块补契约测试,其中 4 个是已经存在的;而真正缺的那一个(hive)在原文里根本没被点出来,最可能被漏掉。 +- **会写出编译不过的删除补丁**:按「7 个连接器」去删分片类型,漏掉 trino,`TrinoScanRange` 立刻编译失败。这类错误会被编译挡住,代价只是返工,但它会让人怀疑整份清单的可信度。 +- **会改坏正在跑的功能**:把 `HIVE_DEFAULT_PARTITION` 下沉到 hive 连接器,引擎自己的分区路径解析和 paimon 的空分区归一化都会断,而且方向上让 fe-core 依赖插件——违反本阶段「fe-core 只出不进」的纪律。这一条是清单里唯一有真实破坏力的。 +- **会删掉有价值的解释**:把压缩类型调整方法的 javadoc「去 Hadoop 化」,等于删掉唯一说明这个钩子为何存在的段落,下一个维护者很可能把 LZ4 重映射写回通用扫描节点——那才是真正的中立性违规。 +- **会白花一次跨 8 个连接器的改名**:按「给同名反向的失效接口改名」去动活着的那一组,触及 8 个连接器加 fe-core;而正确做法是把死的那一整套删掉,名字冲突自然消失。 + +至于规模数字,本身不影响正确性,但它们出现在「现状概览」表里,是别人引用最多的部分;错的数字会一路传下去。 + +修文档而不是留个勘误在别处,是因为读那份文档的人不会同时读勘误。 + +## 四、用一个最小例子说明 + +| 那份文档怎么说 | 一个人照着动手会发生什么 | 实测事实 | +|---|---|---| +| 8 个连接器没有一个调用契约校验器,这 4 条规则今天完全没被验证 | 在 8 个连接器模块各加一个契约测试——其中 4 个是重复建设 | 4 个连接器已经在调,只差 hive 一个(它是唯一声明分区哈希写的连接器) | +| 全部 7 个连接器实现了 `getRangeType()` | 删除时漏掉 trino,编译直接失败在 `TrinoScanRange.java:79` | 是 8 个 | +| 写侧分区规格有「空规格=另一回事」的三态 | 去改一个不存在的三态,或误以为返回空规格是合法的第二种语义 | 只有 `null` / 非空规格两态,接口文档写得很明确 | +| `HIVE_DEFAULT_PARTITION` 下沉到 hive 连接器 | fe-core 的分区路径解析与 paimon 的归一化都会断,fe-core 反向依赖插件 | 引擎和 paimon 都在用,引擎侧还有第二份同串定义 | +| `adjustFileCompressType` 的文档「去 Hadoop 化」 | 删掉唯一解释这个钩子为何存在的段落 | 那段说明本身就是「把数据源专有事实圈在连接器里」的正面示范 | + +## 五、解决方案 + +### 5.1 目标状态 + +改完之后,`plan-doc/connector-api-spi-design-review-2026-07-25.md`: + +1. 头部(现在第 6 行「本文只做分析和建议,不改动任何代码。」之后)多出一小段,大意是:同一天另有一轮独立重做的审查,结论与本任务空间的报告 `plan-doc/connector-public-interface-cleanup/audit-report.md` 及其 `tasks/` 目录并存;两份都保留,因为两轮独立结论的一致部分本身就是可信度最强的证据,分歧部分才是需要人拍板的地方;本文中经交叉核对修正过的地方均以「(交叉核对修正)」标注。 +2. 5 处事实错误就地改成实测事实,涉及数字的直接换数字,涉及结论推导的把作废的推导一起删掉。 +3. 3 条结论按 2.5 节反转,行动清单(第 625 行起那一节)里对应的条目同步改掉,避免正文改了、清单还写着旧结论。 + +**不新增小节、不改写它的分析框架、不把本轮报告的内容搬进去。** + +### 5.2 改动清单 + +| 那份文档的位置 | 现在写的 | 改成 | +|---|---|---| +| 第 6 行之后 | — | 新增头部说明段(见 5.1 第 1 点) | +| 第 28 行 | 约 9800 行 | 10149 行 | +| 第 36 行 | `Connector` 32 | 34 | +| 第 39 行 | `ConnectorSession` 14 | 15 | +| 第 164 行 | 把 `adjustFileCompressType` 列为源专有语义混入 | 整行从该表删除,并在表后补一句:它的钩子形态与 javadoc 说明是正面示范,不算中立性违规(它仍然出现在第 551 行的 thrift 类型清单里,那一条另算,见 5.3) | +| 第 183–184 行 | 「`adjustFileCompressType` 把方法名和文档改成中立表述即可」 | 删掉这半句(方法名本来就中立);`HIVE_DEFAULT_PARTITION` 那半句改成:不能下沉,理由见 2.5 第 2 条的三处引用 | +| 第 186 行 | `HIVE_DEFAULT_PARTITION`「应该由 hive 连接器持有」 | 改成保持在公共层,并保留「连接器可声明哪些值代表 NULL」这条中立能力的说法 | +| 第 211–212 行 | 两个方法都没有默认实现 / 7 个连接器 | 分片上那个是抽象方法(8 个连接器被迫实现,含 trino);扫描计划提供者上那个有默认值 `FILE_SCAN`,只有 hive / es / jdbc 做了等价覆写 | +| 第 417 行 | 空 spec = 另一回事 | 删掉这一条(同小节另两条保留),并注明接口文档已明确 `null`(不是空规格)表示未分区且给了理由 | +| 第 475 行 | 建议给其中一组失效接口改名 | 改成:删掉推模型那一整套(零连接器调用、引擎侧履约不了分区粒度契约),名字冲突随之消失;并指向本任务空间里《删除推模型的缓存失效接口》那份任务文档 | +| 第 529–531 行 | 8 个真实连接器没有一个调用过 | 4 个连接器在调(列出 4 个文件与行号);真实缺口=唯一声明分区哈希写的 hive 没有调用点 | +| 第 532 行与第 536 行 | 「这 4 条规则今天完全没有被验证」「注释描述了并不存在的机制」(两句不相邻:前一句在 532 行,后一句在 536 行的建议段里) | 两句都作废:改成 4 条规则里两条有真实连接器正样本(iceberg / maxcompute),涉及分区哈希写的两条没有;注释描述的机制是部分已实现 | +| 第 589–591 行(`ConnectorContractValidator` 的执行方式与注释不符) | 指向上面那条错误结论 | 整条降级为「注释与实现基本一致,缺口只在 hive」,或直接删除该条并在原处留一行说明它被交叉核对推翻 | +| 第 636–637 行 | 「顺带减少 7 个连接器的无效实现」 | 8 个 | +| 第 642 行 | 让每个连接器的契约测试真正调用校验器 | 收窄为:给 hive 补一个契约测试调用点(其余 4 家已有;hudi / paimon / trino 不声明任何被检查的能力位,补了也只是恒真断言,可选) | +| 第 650 行 | 常量下沉 + 文档去 Hadoop 化 | 两半都撤销,替换为 2.5 第 2、3 条的结论 | +| 第 659 行 | 消除 `null` vs 空集合的三态编码 | 保留,但把写侧分区规格从这条的适用范围里去掉 | +| 第 688 行 | `ConnectorPartitionInfo` 8 个构造函数 | 6 个 | + +改动时在每处修改后缀一个统一标记(例如「(交叉核对修正)」),让后来的读者能一眼看出哪些句子被改过、哪些是原稿。 + +### 5.3 明确不要顺手做的事 + +- **不要用 `audit-report.md` 覆盖或重写那份文档。** 保留两份的理由已经写在 5.1 的头部说明里。 +- **不要顺手校对它其余的数字与结论。** 本任务只动经实测的这 8 处;未经核实的地方保持原样,比改成一个没人验过的新数字更安全。 +- **不要顺手改 Java。** 删除分片类型枚举族、删除推模型失效接口、给 hive 补契约测试,各自是本任务空间里独立的代码任务(见第八节的文件名)。本任务改完之后 `git diff --stat` 应该只有一个 markdown 文件。 +- **不要把 `adjustFileCompressType` 从第 551 行的 thrift 类型清单里删掉。** 它的入参是 `TFileCompressType`,这一条(公共接口签名里出现 thrift 类型)依然成立,与「不算中立性违规」的那条结论并不矛盾——一处是语义中立性,一处是类型依赖。 +- **不要把本轮报告附录 C.3 那六条补记搬进那份文档。** 附录 C.3 是「那份文档独有、经核实成立、应当并入的六条」——分片格式默认值 `"jni"`、建库布尔位与建库方法必须同进同退、带快照与不带快照的重载堆叠、thrift 返回类型写成内联全限定名、「提供者无状态」与「释放跨调用读事务」自相矛盾、按名字寻址导致异构网关拿不到表注释。那些是本轮的内容,各有归属任务。 + +## 六、怎么验证 + +本任务不改 Java,所以**全反应堆 test-compile 不是本任务的验收信号**(如果有 Java 变更出现,说明范围越界了,应当退回)。验收靠两件事:改动后逐条重跑证据命令,和对原始错误字符串做「已消失」断言。 + +改完后在仓库根依次执行,逐条核对输出与文档里写的一致: + +```bash +# 契约校验器的真实调用点:应有 4 个连接器测试文件 +grep -rn "ConnectorContractValidator.validate" fe/fe-connector/*/src/test | sort +# 唯一声明分区哈希写的连接器:应只有 hive 的 main +grep -rn "boolean requiresPartitionHashWrite" fe/fe-connector/*/src/main +# 分片类型实现者:应有 8 个连接器(含 trino) +grep -rln "ConnectorScanRangeType" fe/fe-connector/*/src/main | sort +# 两个方法的默认值差异 +grep -n "getRangeType" fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanRange.java +grep -n -A2 "getScanRangeType" fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java +# 写侧分区规格的两态契约 +grep -n -B12 "getWritePartitioning" fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/write/ConnectorWritePlanProvider.java +# 规模数字 +find fe/fe-connector/fe-connector-api/src/main/java -name "*.java" | wc -l +find fe/fe-connector/fe-connector-api/src/main/java -name "*.java" -exec cat {} + | wc -l +grep -c "public ConnectorPartitionInfo(" fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorPartitionInfo.java +# 分区空值哨兵不能下沉的三处证据 +grep -rn "HIVE_DEFAULT_PARTITION" fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FilePartitionUtils.java \ + fe/fe-core/src/main/java/org/apache/doris/datasource/TablePartitionValues.java \ + fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java +``` + +原始错误字符串必须全部消失(下面每条都应无输出): + +```bash +cd plan-doc && grep -n "8 个真实连接器没有任何一个" connector-api-spi-design-review-2026-07-25.md +grep -n "全部 7 个连接器" connector-api-spi-design-review-2026-07-25.md +grep -n "约 9800 行" connector-api-spi-design-review-2026-07-25.md +grep -n "空 spec = 另一回事" connector-api-spi-design-review-2026-07-25.md +grep -n "8 个构造函数" connector-api-spi-design-review-2026-07-25.md +``` + +另外人工确认两点:头部说明段存在且指向的相对路径能打开;文末行动清单里没有残留与正文相反的旧结论(第 636–660 行整段读一遍)。 + +不需要单元测试、不需要变异验证、不需要端到端回归——本任务不产生任何运行时行为。 + +## 七、风险与回退 + +风险低:单文件文档改动。 + +> **⚠ 这句回退方式在动手时是错的(落地时已修正)**:那份文档当时**从未被 git 跟踪过**(任何分支、任何提交里都不存在,也没有被 ignore,只是从来没人 `git add` 过)。所以 `git checkout -- <该文件>` 只会报 `pathspec did not match`,也没有任何 commit 可以 revert。落地时先把它**原样入库**成一个独立提交,才使"可 diff、可回退"成立。 + +唯一真实风险是**改过头**:一边改一边顺手把整份文档拉平成本轮报告的口径,那样就丢掉了「两轮独立结论」这个可信度证据,也丢掉了那份文档独有的、经核实成立的若干条(接口规模的精确计数、写特性按表缺口、thrift 出现位置清单)。缓解办法就是 5.3 的第一、二条:只动清单里那 8 处 + 头部,改动处统一加标记,提交前用 `git diff` 逐行过一遍。 + +## 八、相关背景 + +- `plan-doc/connector-public-interface-cleanup/audit-report.md` 附录 C:本任务的全部素材来源。C.2 是这 5 处事实错误与 3 处结论改动的原始记录,C.5 是「两份都保留」的处置建议。 +- 同一报告附录 C.1:两轮互有胜负的地方,动手前值得读一遍,避免把那份文档比本轮更准的部分也一起改掉。 +- 同一报告附录 C.3:那份文档独有、经核实成立的六条,它们属于本轮报告与其它任务的范围,**不要**写回那份文档。 +- 本任务空间 `tasks/13-delete-scan-range-type-enum.md`:分片类型枚举族的实际删除任务,本任务只是把「7 个」改成「8 个」,真正删代码在那里。 +- 本任务空间 `tasks/14-delete-push-model-cache-invalidation.md`:推模型失效接口的实际删除任务,对应 2.5 第 1 条反转后的结论。 +- 本任务空间 `tasks/08-fix-stale-interface-docs.md`:那份文档里「接口文档与实现互相矛盾」那一批的落地任务;其中关于契约校验器注释的那一条需要按本任务修正后的口径来做,不要再按「注释描述了不存在的机制」处理。 + +--- + +## 九、落地记录(2026-07-26,三个提交) + +**提交**:`[doc](catalog) check in the same-day SPI design review, unmodified`(原样入库)、`[doc](catalog) date-stamp the SPI design review against the tree it now faces`(标注修订)、`[doc](catalog) correct three stale code comments the review cross-check turned up`(代码注释)。 + +### 9.1 动手前的重侦察推翻了本文的前提 + +本文写于 44 个提交之前。开工前用 16 个并行核查单元回到 HEAD 逐条复核,结论是**不能照本文直接施工**: + +1. **目标文档从未被 git 跟踪**(见第七节的修正框)。本文第六节那批「原始错误字符串必须消失」的 grep 断言,前提也随处置方案改变而失效——见 9.3。 +2. **本文列的 8 条勘误自己也过期了**:4 条仍然成立(其中 1 条要收窄、1 条要改时态)、3 条已被代码变更取代、**1 条彻底失去对象**。 + - 第 2.2 条(把「7 个连接器」改成「8 个」)已 **moot**:分片类型枚举族整族已被删除。照本文改,等于在那份文档里新写一条指向已删符号的待办。 + - 第 2.4 条的数字**只剩 3 个还准**:`ConnectorSession` 15、`ConnectorPartitionInfo` 6、`ConnectorType` 7 未变;模块规模已是 101 文件 / 10978 行,`Connector` 已从 34 掉到 21。 + - 第 2.5 第 2 条的三条硬约束**第 3 条已假**:引擎侧那份重复定义已经删掉,全 FE 树现在只有一处定义;符号也已改名。本文漏列了引擎的第三个消费者(渲染 `partition_values()` 表函数那一格)。 + - 第 2.5 第 1 条仍然成立,但要改时态:本文写的是「另有任务负责删代码」(将来时),实际那一整套已经删完。 +3. **本文只覆盖了 8 处,而实际失效的远不止**:去重后约 **26 个独立论断**失效,散落在 40 余处文字。其中至少 5 处照着做会重复劳动、扑空、或改坏正在跑的功能。 + +### 9.2 处置方案改为「加状态戳 + 重编两块」(用户拍板) + +本文第五节原定的「就地改掉这 8 处、其余一字不动」在复核后已不成立(会留下 26 处误导)。实际执行的是:**分析正文一律保留**(它是评审快照,价值在"当时看到了什么"),只加三类标注——状态戳 36 处、交叉核对修正 15 处、就地重算 3 处——并把「现状概览」两张表与文末 17 条行动清单按 HEAD 重编。理由是"当前现状"的权威位置**已经搬进代码注释**(包级说明的规则一/规则三、各域接口的最少实现集、契约校验器的实际覆盖段落),markdown 不该再当第二真相源。 + +### 9.3 本文第六节的验收口径需要按此调整 + +- 「原始错误字符串必须全部消失」这批断言里,**只有 2 条仍适用**(`约 9800 行`、`8 个构造函数`——这两处是数字,已就地换掉)。另外 3 条(`8 个真实连接器没有任何一个`、`全部 7 个连接器`、`空 spec = 另一回事`)按新方案**刻意保留原句**,紧随其后跟一个交叉核对修正块;用原 grep 去断言"已消失"会误判。 +- 本文 5.2 那张改动清单里,第 164 / 183–186 / 211–212 / 417 / 475 / 529–531 / 532 / 536 / 589–591 / 650 各行都改为"保留原句 + 加修正块",不是就地替换。 + +### 9.4 复核额外查出、本文没有的三条事实错误 + +前两条在本任务空间的交接文档里已挂号,第三条是本次新发现: + +1. `ConnectorRewriteDriver` 里引用了树中已不存在的旧类名。**交接文档说"两个类名都不存在"只对一半**——另一个类是活的,只是搬进了 iceberg 连接器;同时还有一处交接文档没发现的同类引用(在 iceberg 连接器的重写规划器里,两个旧类名都已改名)。 +2. `ConnectorWritePlanProviderDefaultsTest` 的类注释说默认值"必须是空列表",而接口默认返回 `null`、**该测试自己的断言也是断言 `null`**——注释与它守护的断言互相打脸。 +3. **(新发现)** `ConnectorRewriteStatistics` 的类注释说"前三个数字是求和、第四个来自事务",实际求和的是第一、三、四个,来自事务的是**第二个**(引擎侧构造点的注释是对的,值对象的注释说反了)。照注释理解会把字段对错位。 + +三条都已修,是本轮第三个提交。**另有一条经复核确认仍然成立、但至今没有任何任务认领**:统计接口「列举文件大小」的异常契约,接口 javadoc 要求吞异常返回空集合,而唯一实现与新写进包级说明的「响亮失败」规则都说必须抛——本轮用户明确只修注释、不动这条契约,已登记待排期。 diff --git a/regression-test/data/external_table_p0/nereids_commands/test_nereids_refresh_catalog.out b/regression-test/data/external_table_p0/nereids_commands/test_nereids_refresh_catalog.out index a71ca04ae66778..fc034c6c5b2b11 100644 --- a/regression-test/data/external_table_p0/nereids_commands/test_nereids_refresh_catalog.out +++ b/regression-test/data/external_table_p0/nereids_commands/test_nereids_refresh_catalog.out @@ -32,25 +32,25 @@ new_mysql_table1 new_mysql_table2 -- !show_create_table -- -new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL\n) ENGINE=JDBC_EXTERNAL_TABLE; +new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL\n) ENGINE=jdbc; -- !preceding_refresh_table -- -new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL\n) ENGINE=JDBC_EXTERNAL_TABLE; +new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL\n) ENGINE=jdbc; -- !subsequent_refresh_table -- -new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL\n) ENGINE=JDBC_EXTERNAL_TABLE; +new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL\n) ENGINE=jdbc; -- !preceding_refresh_table -- -new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL\n) ENGINE=JDBC_EXTERNAL_TABLE; +new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL\n) ENGINE=jdbc; -- !subsequent_refresh_table -- -new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL,\n `new_column_1` int NULL\n) ENGINE=JDBC_EXTERNAL_TABLE; +new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL,\n `new_column_1` int NULL\n) ENGINE=jdbc; -- !preceding_refresh_table -- -new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL,\n `new_column_1` int NULL\n) ENGINE=JDBC_EXTERNAL_TABLE; +new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL,\n `new_column_1` int NULL\n) ENGINE=jdbc; -- !subsequent_refresh_table -- -new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL,\n `new_column_1` int NULL,\n `new_column_2` int NULL\n) ENGINE=JDBC_EXTERNAL_TABLE; +new_mysql_table2 CREATE TABLE `new_mysql_table2` (\n `id` int NULL,\n `name` varchar(20) NULL,\n `new_column` int NULL,\n `new_column_1` int NULL,\n `new_column_2` int NULL\n) ENGINE=jdbc; -- !preceding_drop_external_database -- new_mysql_db diff --git a/regression-test/data/external_table_p0/paimon/test_paimon_table_properties.out b/regression-test/data/external_table_p0/paimon/test_paimon_table_properties.out index 685395322056e1..0601d62955f4c1 100644 --- a/regression-test/data/external_table_p0/paimon/test_paimon_table_properties.out +++ b/regression-test/data/external_table_p0/paimon/test_paimon_table_properties.out @@ -1,4 +1,4 @@ -- This file is automatically generated. You should know what you did if you want to edit this -- !show_create_table -- -ts_scale_orc CREATE TABLE `ts_scale_orc` (\n `id` int NULL,\n `ts1` datetimev2(1) NULL,\n `ts2` datetimev2(2) NULL,\n `ts3` datetimev2(3) NULL,\n `ts4` datetimev2(4) NULL,\n `ts5` datetimev2(5) NULL,\n `ts6` datetimev2(6) NULL,\n `ts7` datetimev2(6) NULL,\n `ts8` datetimev2(6) NULL,\n `ts9` datetimev2(6) NULL,\n `ts11` datetimev2(1) NULL,\n `ts12` datetimev2(2) NULL,\n `ts13` datetimev2(3) NULL,\n `ts14` datetimev2(4) NULL,\n `ts15` datetimev2(5) NULL,\n `ts16` datetimev2(6) NULL,\n `ts17` datetimev2(6) NULL,\n `ts18` datetimev2(6) NULL,\n `ts19` datetimev2(6) NULL\n) ENGINE=PAIMON_EXTERNAL_TABLE\nLOCATION 's3://warehouse/wh/flink_paimon.db/ts_scale_orc'\nPROPERTIES (\n "path" = "s3://warehouse/wh/flink_paimon.db/ts_scale_orc",\n "write-only" = "true",\n "file.format" = "orc"\n); +ts_scale_orc CREATE TABLE `ts_scale_orc` (\n `id` int NULL,\n `ts1` datetimev2(1) NULL,\n `ts2` datetimev2(2) NULL,\n `ts3` datetimev2(3) NULL,\n `ts4` datetimev2(4) NULL,\n `ts5` datetimev2(5) NULL,\n `ts6` datetimev2(6) NULL,\n `ts7` datetimev2(6) NULL,\n `ts8` datetimev2(6) NULL,\n `ts9` datetimev2(6) NULL,\n `ts11` datetimev2(1) NULL,\n `ts12` datetimev2(2) NULL,\n `ts13` datetimev2(3) NULL,\n `ts14` datetimev2(4) NULL,\n `ts15` datetimev2(5) NULL,\n `ts16` datetimev2(6) NULL,\n `ts17` datetimev2(6) NULL,\n `ts18` datetimev2(6) NULL,\n `ts19` datetimev2(6) NULL\n) ENGINE=paimon\nLOCATION 's3://warehouse/wh/flink_paimon.db/ts_scale_orc'\nPROPERTIES (\n "path" = "s3://warehouse/wh/flink_paimon.db/ts_scale_orc",\n "write-only" = "true",\n "file.format" = "orc"\n); diff --git a/regression-test/data/external_table_p2/maxcompute/test_max_compute_create_table.out b/regression-test/data/external_table_p2/maxcompute/test_max_compute_create_table.out index 9fca96546e8301..c2d561c0ec5d12 100644 --- a/regression-test/data/external_table_p2/maxcompute/test_max_compute_create_table.out +++ b/regression-test/data/external_table_p2/maxcompute/test_max_compute_create_table.out @@ -1,34 +1,34 @@ -- This file is automatically generated. You should know what you did if you want to edit this -- !test1_show_create_table -- -test_mc_basic_table CREATE TABLE `test_mc_basic_table` (\n `id` int NULL,\n `name` text NULL,\n `age` int NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_basic_table CREATE TABLE `test_mc_basic_table` (\n `id` int NULL,\n `name` text NULL,\n `age` int NULL\n) ENGINE=maxcompute; -- !test2_show_create_table -- -test_mc_all_types_comprehensive CREATE TABLE `test_mc_all_types_comprehensive` (\n `id` int NULL,\n `bool_col` boolean NULL,\n `tinyint_col` tinyint NULL,\n `smallint_col` smallint NULL,\n `int_col` int NULL,\n `bigint_col` bigint NULL,\n `float_col` float NULL,\n `double_col` double NULL,\n `decimal_col1` decimalv3(9,0) NULL,\n `decimal_col2` decimalv3(8,4) NULL,\n `decimal_col3` decimalv3(18,6) NULL,\n `decimal_col4` decimalv3(38,12) NULL,\n `string_col` text NULL,\n `varchar_col1` varchar(100) NULL,\n `varchar_col2` varchar(65533) NULL,\n `char_col1` char(50) NULL,\n `char_col2` character(255) NULL,\n `date_col` datev2 NULL,\n `datetime_col` datetimev2(3) NULL,\n `t_array_string` array NULL,\n `t_array_int` array NULL,\n `t_array_bigint` array NULL,\n `t_array_float` array NULL,\n `t_array_double` array NULL,\n `t_array_boolean` array NULL,\n `t_map_string` map NULL,\n `t_map_int` map NULL,\n `t_map_bigint` map NULL,\n `t_map_float` map NULL,\n `t_map_double` map NULL,\n `t_struct_simple` struct NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_all_types_comprehensive CREATE TABLE `test_mc_all_types_comprehensive` (\n `id` int NULL,\n `bool_col` boolean NULL,\n `tinyint_col` tinyint NULL,\n `smallint_col` smallint NULL,\n `int_col` int NULL,\n `bigint_col` bigint NULL,\n `float_col` float NULL,\n `double_col` double NULL,\n `decimal_col1` decimalv3(9,0) NULL,\n `decimal_col2` decimalv3(8,4) NULL,\n `decimal_col3` decimalv3(18,6) NULL,\n `decimal_col4` decimalv3(38,12) NULL,\n `string_col` text NULL,\n `varchar_col1` varchar(100) NULL,\n `varchar_col2` varchar(65533) NULL,\n `char_col1` char(50) NULL,\n `char_col2` character(255) NULL,\n `date_col` datev2 NULL,\n `datetime_col` datetimev2(3) NULL,\n `t_array_string` array NULL,\n `t_array_int` array NULL,\n `t_array_bigint` array NULL,\n `t_array_float` array NULL,\n `t_array_double` array NULL,\n `t_array_boolean` array NULL,\n `t_map_string` map NULL,\n `t_map_int` map NULL,\n `t_map_bigint` map NULL,\n `t_map_float` map NULL,\n `t_map_double` map NULL,\n `t_struct_simple` struct NULL\n) ENGINE=maxcompute; -- !test3_show_create_table -- -test_mc_partition_table CREATE TABLE `test_mc_partition_table` (\n `id` int NULL,\n `name` text NULL,\n `amount` double NULL,\n `ds` text NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_partition_table CREATE TABLE `test_mc_partition_table` (\n `id` int NULL,\n `name` text NULL,\n `amount` double NULL,\n `ds` text NULL\n) ENGINE=maxcompute; -- !test4_show_create_table -- -test_mc_distributed_table CREATE TABLE `test_mc_distributed_table` (\n `id` int NULL,\n `name` text NULL,\n `value` int NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_distributed_table CREATE TABLE `test_mc_distributed_table` (\n `id` int NULL,\n `name` text NULL,\n `value` int NULL\n) ENGINE=maxcompute; -- !test5_show_create_table -- -test_mc_partition_distributed_table CREATE TABLE `test_mc_partition_distributed_table` (\n `id` int NULL,\n `name` text NULL,\n `value` int NULL,\n `ds` text NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_partition_distributed_table CREATE TABLE `test_mc_partition_distributed_table` (\n `id` int NULL,\n `name` text NULL,\n `value` int NULL,\n `ds` text NULL\n) ENGINE=maxcompute; -- !test6_show_create_table -- -test_mc_table_with_comment CREATE TABLE `test_mc_table_with_comment` (\n `id` int NULL COMMENT "User ID",\n `name` text NULL COMMENT "User Name"\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_table_with_comment CREATE TABLE `test_mc_table_with_comment` (\n `id` int NULL COMMENT "User ID",\n `name` text NULL COMMENT "User Name"\n) ENGINE=maxcompute; -- !test8_show_create_table -- -test_mc_table_with_lifecycle CREATE TABLE `test_mc_table_with_lifecycle` (\n `id` int NULL,\n `name` text NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_table_with_lifecycle CREATE TABLE `test_mc_table_with_lifecycle` (\n `id` int NULL,\n `name` text NULL\n) ENGINE=maxcompute; -- !test9_show_create_table -- -test_mc_if_not_exists_table CREATE TABLE `test_mc_if_not_exists_table` (\n `id` int NULL,\n `name` text NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_if_not_exists_table CREATE TABLE `test_mc_if_not_exists_table` (\n `id` int NULL,\n `name` text NULL\n) ENGINE=maxcompute; -- !test10_show_create_table -- -test_mc_array_type_table CREATE TABLE `test_mc_array_type_table` (\n `id` int NULL,\n `tags` array NULL,\n `scores` array NULL,\n `values` array NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_array_type_table CREATE TABLE `test_mc_array_type_table` (\n `id` int NULL,\n `tags` array NULL,\n `scores` array NULL,\n `values` array NULL\n) ENGINE=maxcompute; -- !test11_show_create_table -- -test_mc_map_type_table CREATE TABLE `test_mc_map_type_table` (\n `id` int NULL,\n `properties` map NULL,\n `metrics` map NULL,\n `config` map NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_map_type_table CREATE TABLE `test_mc_map_type_table` (\n `id` int NULL,\n `properties` map NULL,\n `metrics` map NULL,\n `config` map NULL\n) ENGINE=maxcompute; -- !test12_show_create_table -- -test_mc_struct_type_table CREATE TABLE `test_mc_struct_type_table` (\n `id` int NULL,\n `person` struct NULL,\n `address` struct NULL\n) ENGINE=MAX_COMPUTE_EXTERNAL_TABLE; +test_mc_struct_type_table CREATE TABLE `test_mc_struct_type_table` (\n `id` int NULL,\n `person` struct NULL,\n `address` struct NULL\n) ENGINE=maxcompute; diff --git a/regression-test/suites/external_table_p0/hive/ddl/test_hive_ddl.groovy b/regression-test/suites/external_table_p0/hive/ddl/test_hive_ddl.groovy index 23e3e37d8646fa..b654819826ed27 100644 --- a/regression-test/suites/external_table_p0/hive/ddl/test_hive_ddl.groovy +++ b/regression-test/suites/external_table_p0/hive/ddl/test_hive_ddl.groovy @@ -439,7 +439,7 @@ suite("test_hive_ddl", "p0,external") { 'replication_num' = '1' ); """ - exception "Cannot create olap table out of internal catalog. Make sure 'engine' type is specified when use the catalog: test_hive_ddl" + exception "Engine 'olap' does not match catalog 'test_hive_ddl'." } // test default engine is hive in hive catalog @@ -475,7 +475,7 @@ suite("test_hive_ddl", "p0,external") { `col` STRING COMMENT 'col' ) ENGINE=hive """ - exception "Cannot create hive table in internal catalog, should switch to hive catalog." + exception "Engine 'hive' does not match catalog 'internal'." } sql """ DROP DATABASE IF EXISTS test_olap_cross_catalog """ @@ -724,12 +724,12 @@ suite("test_hive_ddl", "p0,external") { test { sql """ create table err_tb (id int) engine = iceberg """ - exception "This catalog can only use `hive` engine" + exception "Engine 'iceberg' does not match catalog '${catalog_name}'." } test { sql """ create table err_tb (id int) engine = jdbc """ - exception "This catalog can only use `hive` engine" + exception "Engine 'jdbc' does not match catalog '${catalog_name}'." } sql """ drop database test_hive_db_error_tbl """ diff --git a/regression-test/suites/external_table_p0/iceberg/test_iceberg_on_hms_gateway_ddl_parity.groovy b/regression-test/suites/external_table_p0/iceberg/test_iceberg_on_hms_gateway_ddl_parity.groovy new file mode 100644 index 00000000000000..2ecb31e0358fb5 --- /dev/null +++ b/regression-test/suites/external_table_p0/iceberg/test_iceberg_on_hms_gateway_ddl_parity.groovy @@ -0,0 +1,156 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// An iceberg table stored in HMS is reachable through two catalogs: a dedicated iceberg catalog +// ('type'='iceberg', 'iceberg.catalog.type'='hms') and a heterogeneous HMS gateway catalog ('type'='hms') +// that serves plain-hive and iceberg tables side by side. Everything the dedicated catalog can do to such a +// table, the gateway must be able to do as well; this suite pins the two places where it could not. +// +// 1. Nested (dotted-path) column DDL. The gateway forwards handle-carrying ALTER operations to its embedded +// iceberg sibling, but the five path-addressed column ops were not forwarded, so nested ADD / RENAME / +// MODIFY COMMENT / DROP COLUMN failed with "not supported" through the gateway while succeeding through +// the dedicated catalog on the very same table. +// 2. The table comment. The comment accessor addresses a table by NAME, not by handle, so the gateway cannot +// route it by handle type the way it routes the ALTER ops; it answered the empty default, and an +// iceberg-on-HMS table rendered a blank COMMENT clause / TABLE_COMMENT / SHOW TABLE STATUS Comment. +suite("test_iceberg_on_hms_gateway_ddl_parity", "p0,external") { + String enabled = context.config.otherConfigs.get("enableHiveTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable hive test.") + return + } + + for (String hivePrefix : ["hive2", "hive3"]) { + setHivePrefix(hivePrefix) + String hmsPort = context.config.otherConfigs.get(hivePrefix + "HmsPort") + String hdfsPort = context.config.otherConfigs.get(hivePrefix + "HdfsPort") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + + String icebergCatalog = "test_iceberg_on_hms_gateway_iceberg_${hivePrefix}" + String gatewayCatalog = "test_iceberg_on_hms_gateway_hms_${hivePrefix}" + String dbName = "test_iceberg_on_hms_gateway_db" + String tableName = "gateway_nested_parity" + String tableComment = "comment set through the iceberg catalog" + + try { + sql """drop catalog if exists ${icebergCatalog}""" + sql """create catalog ${icebergCatalog} properties ( + 'type'='iceberg', + 'iceberg.catalog.type'='hms', + 'hive.metastore.uris' = 'thrift://${externalEnvIp}:${hmsPort}', + 'fs.defaultFS' = 'hdfs://${externalEnvIp}:${hdfsPort}', + 'use_meta_cache' = 'true' + );""" + + sql """drop catalog if exists ${gatewayCatalog}""" + sql """create catalog ${gatewayCatalog} properties ( + 'type'='hms', + 'hive.metastore.uris' = 'thrift://${externalEnvIp}:${hmsPort}', + 'fs.defaultFS' = 'hdfs://${externalEnvIp}:${hdfsPort}', + 'use_meta_cache' = 'true' + );""" + + sql """set enable_fallback_to_original_planner=false;""" + + // ---- create the table through the DEDICATED iceberg catalog ---- + sql """switch ${icebergCatalog}""" + sql """create database if not exists ${dbName}""" + sql """use ${dbName}""" + sql """drop table if exists ${tableName}""" + sql """ + create table ${tableName} ( + id INT NOT NULL, + s STRUCT, + arr ARRAY>, + m MAP> + ) COMMENT '${tableComment}' + """ + sql """insert into ${tableName} values (1, STRUCT(10, 'old'), ARRAY(STRUCT(100)), MAP('k', STRUCT(1000)))""" + + // ---- 1. nested column DDL, driven entirely through the GATEWAY catalog ---- + sql """switch ${gatewayCatalog}""" + sql """use ${dbName}""" + + sql """alter table ${tableName} add column s.c STRING NULL COMMENT 'added through the gateway'""" + sql """alter table ${tableName} add column arr.element.y INT NULL""" + sql """alter table ${tableName} add column m.value.y INT NULL""" + sql """alter table ${tableName} add column s.drop_me STRING NULL""" + sql """alter table ${tableName} modify column s.a BIGINT""" + sql """alter table ${tableName} rename column s.c TO c2""" + sql """alter table ${tableName} modify column s.`c2` COMMENT 'renamed through the gateway'""" + sql """alter table ${tableName} drop column s.drop_me""" + + // The dedicated catalog must see every one of those changes: same table, same metadata. + sql """switch ${icebergCatalog}""" + sql """use ${dbName}""" + def viaIceberg = sql """desc ${tableName}""" + def structTypeViaIceberg = viaIceberg.find { it[0] == "s" }[1].toString().toLowerCase() + assertTrue(structTypeViaIceberg.contains("c2"), + "nested ADD/RENAME COLUMN issued through the gateway is not visible on the table: ${structTypeViaIceberg}") + assertTrue(structTypeViaIceberg.contains("bigint"), + "nested MODIFY COLUMN issued through the gateway is not visible on the table: ${structTypeViaIceberg}") + assertFalse(structTypeViaIceberg.contains("drop_me"), + "nested DROP COLUMN issued through the gateway is not visible on the table: ${structTypeViaIceberg}") + + // The same statement must keep failing loud for a PLAIN hive table (nothing to delegate to). + sql """switch ${gatewayCatalog}""" + sql """use ${dbName}""" + String plainHiveTable = "gateway_plain_hive" + sql """drop table if exists ${plainHiveTable}""" + sql """create table ${plainHiveTable} (id INT, s STRUCT) ENGINE=hive""" + test { + sql """alter table ${plainHiveTable} add column s.b STRING NULL""" + exception "not supported" + } + + // ---- 2. the table comment must be identical through both catalogs ---- + def commentViaGateway = sql """show create table ${tableName}""" + assertTrue(commentViaGateway[0][1].contains(tableComment), + "SHOW CREATE TABLE through the HMS gateway lost the table comment: ${commentViaGateway[0][1]}") + + def statusViaGateway = sql """show table status like '${tableName}'""" + assertEquals(tableComment, statusViaGateway[0][17], + "SHOW TABLE STATUS through the HMS gateway lost the table comment") + + def infoViaGateway = sql """select TABLE_COMMENT from information_schema.tables + where TABLE_SCHEMA = '${dbName}' and TABLE_NAME = '${tableName}' + and TABLE_CATALOG = '${gatewayCatalog}'""" + def infoViaIceberg = sql """select TABLE_COMMENT from information_schema.tables + where TABLE_SCHEMA = '${dbName}' and TABLE_NAME = '${tableName}' + and TABLE_CATALOG = '${icebergCatalog}'""" + assertEquals(infoViaIceberg[0][0], infoViaGateway[0][0], + "information_schema.tables.TABLE_COMMENT differs between the dedicated iceberg catalog and the " + + "HMS gateway for the same table") + assertEquals(tableComment, infoViaGateway[0][0]) + + // A plain-hive table keeps its historical empty comment (the gateway only answers for iceberg tables). + def plainInfo = sql """select TABLE_COMMENT from information_schema.tables + where TABLE_SCHEMA = '${dbName}' and TABLE_NAME = '${plainHiveTable}' + and TABLE_CATALOG = '${gatewayCatalog}'""" + assertTrue(plainInfo[0][0] == null || plainInfo[0][0].toString().isEmpty(), + "a plain hive table should keep its empty comment, got: ${plainInfo[0][0]}") + } finally { + sql """switch ${icebergCatalog}""" + sql """drop table if exists ${dbName}.${tableName}""" + sql """switch ${gatewayCatalog}""" + sql """drop table if exists ${dbName}.gateway_plain_hive""" + sql """switch internal""" + sql """drop catalog if exists ${gatewayCatalog}""" + sql """drop catalog if exists ${icebergCatalog}""" + } + } +} diff --git a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_create_table.groovy b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_create_table.groovy index 7411b427051e1f..137d23486259d7 100644 --- a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_create_table.groovy +++ b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_create_table.groovy @@ -58,17 +58,17 @@ suite("test_iceberg_create_table", "p0,external") { test { sql """ create table ${db1}.${tb1} (id int) engine = olap """ - exception "Cannot create olap table out of internal catalog. Make sure 'engine' type is specified when use the catalog: ${catalog_name}" + exception "Engine 'olap' does not match catalog '${catalog_name}'." } test { sql """ create table ${db1}.${tb1} (id int) engine = hive """ - exception "This catalog can only use `iceberg` engine" + exception "Engine 'hive' does not match catalog '${catalog_name}'." } test { sql """ create table ${db1}.${tb1} (id int) engine = jdbc """ - exception "This catalog can only use `iceberg` engine" + exception "Engine 'jdbc' does not match catalog '${catalog_name}'." } sql """ create table ${db1}.${tb1} (id int) engine = iceberg """ diff --git a/regression-test/suites/external_table_p0/paimon/test_paimon_like_pushdown.groovy b/regression-test/suites/external_table_p0/paimon/test_paimon_like_pushdown.groovy new file mode 100644 index 00000000000000..06ab981bcbad3c --- /dev/null +++ b/regression-test/suites/external_table_p0/paimon/test_paimon_like_pushdown.groovy @@ -0,0 +1,90 @@ +// 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. + +// WHY: LIKE pushdown to paimon may only ever WIDEN what the source returns, never narrow it. The pushed +// predicate drives paimon's partition and data-file pruning at planning time, so a prefix stricter than the +// user's pattern makes paimon skip files holding matching rows - and the BE-side residual LIKE filter can only +// drop rows from what was read, never read a skipped file back. The result is a query that silently returns +// fewer rows, with no error and no EXPLAIN signal. +// +// The connector used to treat any pattern that did not start with '%' and did end with '%' as a literal prefix +// match, ignoring three things Doris LIKE means: +// * '_' is a single-character wildcard, so 'a_c%' must also match 'abc1'; +// * '\' escapes the next character, so 'a\%%' means "starts with the literal a%", not "starts with a\%"; +// * a '%' left in the middle is still a wildcard, so 'a%b%' is not the literal prefix 'a%b'. +// +// Each value is written in its own INSERT so the rows land in separate data files and file-level pruning is +// deterministically reachable; otherwise a single file would be read regardless and the bug would hide. +suite("test_paimon_like_pushdown", "p0,external") { + String enabled = context.config.otherConfigs.get("enablePaimonTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable paimon test") + return + } + + String minio_port = context.config.otherConfigs.get("iceberg_minio_port") + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String catalog_name = "test_paimon_like_pushdown" + String dbName = "test_paimon_like_pushdown_db" + String tableName = "like_pushdown_tbl" + + spark_paimon_multi """ + create database if not exists paimon.${dbName}; + drop table if exists paimon.${dbName}.${tableName}; + create table paimon.${dbName}.${tableName} (s string) using paimon; + insert into paimon.${dbName}.${tableName} values ('abc1'); + insert into paimon.${dbName}.${tableName} values ('a_c1'); + insert into paimon.${dbName}.${tableName} values ('a%b1'); + """ + + sql """drop catalog if exists ${catalog_name}""" + sql """ + CREATE CATALOG ${catalog_name} PROPERTIES ( + 'type' = 'paimon', + 'warehouse' = 's3://warehouse/wh', + 's3.endpoint' = 'http://${externalEnvIp}:${minio_port}', + 's3.access_key' = 'admin', + 's3.secret_key' = 'password', + 's3.path.style.access' = 'true' + ); + """ + sql """use `${catalog_name}`.`${dbName}`""" + + // Assertions are inline rather than qt_/.out baselines: the expected answer here is the plain meaning + // of LIKE, which is worth stating in the test rather than recording from a run. + def matched = { String pattern -> + sql("select s from ${tableName} where s like '${pattern}' order by s").collect { it[0] } + } + + // '_' is a wildcard: both 'a_c1' and 'abc1' match. Before the fix the connector pushed + // startsWith("a_c") and paimon pruned away the file holding 'abc1'. + assertEquals(["a_c1", "abc1"], matched("a_c%")) + + // '\%' is an escaped literal '%': only 'a%b1' matches. Before the fix the connector pushed + // startsWith("a\\%") - backslash included - which typically matches nothing at all. + assertEquals(["a%b1"], matched("a\\\\%b%")) + + // A '%' in the middle stays a wildcard: 'a%b1' and 'abc1' both match ('abc1' has a 'b' after the 'a'). + // Before the fix the connector pushed startsWith("a%b") as a literal. + assertEquals(["a%b1", "abc1"], matched("a%b%")) + + // The one shape that IS provably a prefix match. Kept as a guard that tightening the check did not + // stop pushing the common, useful case. + assertEquals(["abc1"], matched("abc%")) + + sql """drop catalog if exists ${catalog_name}""" +} diff --git a/regression-test/suites/external_table_p2/hudi/test_hudi_sqlcache.groovy b/regression-test/suites/external_table_p2/hudi/test_hudi_sqlcache.groovy new file mode 100644 index 00000000000000..fc452d0cdcb7b1 --- /dev/null +++ b/regression-test/suites/external_table_p2/hudi/test_hudi_sqlcache.groovy @@ -0,0 +1,85 @@ +// 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. + +// WHY: a partitioned hudi table must be able to use SqlCache. Two independent gates control external +// SqlCache eligibility, so this test satisfies both: +// 1) It is opt-in via `enable_hive_sql_cache` (default false) - set below. Without it BindRelation marks +// every external lakehouse table unsupported and nothing is ever cached. +// 2) The table must report a real data-version token. An UNPARTITIONED hudi table lists no partitions, +// so its token is 0 and it is never cacheable (the version<=0 fail-safe in SqlCacheContext) - hence a +// PARTITIONED table here. +// The bug this exercises: eligibility is gated on (now_millis - table_newest_update_millis) >= quiet window, +// with now first clamped to at least table_newest_update (a guard against FE/metadata clock skew). The hudi +// connector reported its newest-update time as the raw hudi instant (yyyyMMddHHmmssSSS read as a number, +// ~2.0e16) instead of epoch millis (~1.7e12), so the clamp dragged "now" up to the instant and the difference +// was 0 FOREVER - not "0 until the window passes". A partitioned hudi table, and any query joining one, could +// therefore never be cached. The fix converts the instant to genuine wall-clock millis in the connector; a +// write still invalidates through the version token, so no stale results. +// +// The regression env's hudi data is static and long past the quiet window, so this suite is red before the +// fix and green after it, with no timing dependence. +suite("test_hudi_sqlcache", "p2,external") { + String enabled = context.config.otherConfigs.get("enableHudiTest") + if (enabled == null || !enabled.equalsIgnoreCase("true")) { + logger.info("disable hudi test") + return + } + + String catalog_name = "test_hudi_sqlcache" + String externalEnvIp = context.config.otherConfigs.get("externalEnvIp") + String hudiHmsPort = context.config.otherConfigs.get("hudiHmsPort") + String hudiMinioPort = context.config.otherConfigs.get("hudiMinioPort") + String hudiMinioAccessKey = context.config.otherConfigs.get("hudiMinioAccessKey") + String hudiMinioSecretKey = context.config.otherConfigs.get("hudiMinioSecretKey") + + def assertHasCache = { String sqlStr -> + explain { + sql ("physical plan ${sqlStr}") + contains("PhysicalSqlCache") + } + } + + // Both partition-listing paths are exercised: hive-sync reads the names from HMS and pins the instant + // separately from the hudi-metadata path, so the unit conversion has two distinct call sites. + for (String use_hive_sync_partition : ['true', 'false']) { + sql """drop catalog if exists ${catalog_name};""" + sql """ + create catalog if not exists ${catalog_name} properties ( + 'type'='hms', + 'hive.metastore.uris' = 'thrift://${externalEnvIp}:${hudiHmsPort}', + 's3.endpoint' = 'http://${externalEnvIp}:${hudiMinioPort}', + 's3.access_key' = '${hudiMinioAccessKey}', + 's3.secret_key' = '${hudiMinioSecretKey}', + 's3.region' = 'us-east-1', + 'use_path_style' = 'true', + 'use_hive_sync_partition' = '${use_hive_sync_partition}' + ); + """ + + sql """ switch ${catalog_name};""" + sql """ use regression_hudi;""" + sql """ set enable_fallback_to_original_planner=false """ + sql """ set enable_sql_cache=true """ + sql """ set enable_hive_sql_cache=true """ + + // Populate the cache, then assert the plan is served from it. + sql """ select count(*) from one_partition_tb """ + assertHasCache """ select count(*) from one_partition_tb """ + } + + sql """drop catalog if exists ${catalog_name};""" +}